v0.6.56: data retention improvements, tables column double click resize, subagent thinking, files sorting, agentphone integration

This commit is contained in:
Waleed
2026-04-23 23:09:57 -07:00
committed by GitHub
61 changed files with 35506 additions and 368 deletions

View File

@@ -28,6 +28,36 @@ export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AgentPhoneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 150 150' xmlns='http://www.w3.org/2000/svg'>
<path
fill='#23AF58'
stroke='#007F3F'
strokeWidth='0.15'
strokeMiterlimit='10'
d='m139.6 53.3c-1.4-2.3-4.9-3.3-7.6-4.8-2.7-1.3-4.2-2.4-5.7-3.6-1.9-1-2.5-2.7-3.3-3.2s-2.7-1.4-4.5 1.3c-2 2.7-4.5 6.6-6.6 11.1-2.3 5.4-6.3 14.9-6.3 18.9 0.5 4.9 3.1 4.6 6.1 7.2 2.5 2.1 2.8 5.8 1.5 12.5-1.3 6.6-4 12.8-7.8 19.2-3.3 5.1-5.8 8.7-10 9.1-5.3 0.5-12.5-3.1-16.8-5.6-1-0.6-2.5-0.9-3.8-0.2-1.3 0.5-2.2 1.6-3.2 3.3-1.5 2.5-4.6 7.7-5.8 12.2-0.5 3 0 6.4 2.9 9 1.4 1.2 2.8 2.5 4.4 3.4 5 2.8 9.6 4.5 16.5 4.9 5.3 0.2 9.3-1 13.4-3.1 2.4-1.3 6.6-4.2 9.6-7.3l1.1-1.2c2.8-3.1 8.8-10 11.6-14.5 2.3-3.5 4.8-7.4 6.9-12.3 2.9-6.7 4.4-14 5-17.9 1.2-7 2.4-17.5 3.4-31.1 0.1-4.3-0.3-6.1-1-7.3zm-4.5 6.7c-0.5 9.5-1.9 23.3-3.1 30.1-0.9 4.5-2.4 9.6-3.8 13.4-1.1 2.6-3.1 7-5.6 10.8-3.4 5.3-8.4 11.6-12 15.8-6.4 6.6-10.2 9.6-14.2 10.8-2.2 0.9-3.8 1.2-7 1.2-3.4-0.1-8-0.7-11.3-2.2-3-1.2-7-4-6.9-6.8 0.4-3.2 3.3-9.6 5.2-11.9 0.2-0.3 0.5-0.3 0.7-0.2 2.5 1.1 6 3.2 9.6 4.5 2.4 0.9 4.8 1.4 7.3 1.4 3.9 0 6.7-1.2 9.5-3.2 5.6-4.6 9-10.8 12.1-17.5 2-4.3 4.1-11.6 4.4-18.3 0.1-4.9-1.1-8.9-4.5-12.2-1.1-0.7-3-2.1-3-2.8 0-4.2 3.9-13 8.9-22.9 0.2-0.7 0.5-1 1.1-0.7 1.1 0.6 3 1.4 4.6 2.4 2.1 1 5.4 2.4 7.1 3.9 0.9 0.4 1 3 0.9 4.4z'
/>
<path
fill='#23AF58'
d='m104.7 27.8c-1.3-1.5-3.3-1.3-6.2-1.5l-1.9 0.2-7-0.2-31.5 0.2 1.5-9.3c2-1.1 5.1-3.5 5.8-6.3 1-2.8 0.2-5.9-2-7.4-2.3-1.9-5.8-2.4-9.3-0.8-1.6 1-4.7 3.4-5.4 6.9-0.8 4.1 2.4 6.7 4.7 7.9l-1.5 9.1-17.2 0.9c-12.3 1.1-16.3 1.2-20.6 4.3-2 1.3-3 4.5-3.4 9.8-0.6 11.3-0.7 18.7-0.6 28.3 0.4 11.2 0 36.6 3 39.8l-1.2 0.3c-3.8 0.6-4 6.2-0.5 6.6l15.5-1 69.7-7.6c2.5-0.4 4.3-0.9 4.6-4.3l3.7-71.5c0-1.9 0.2-3.6-0.2-4.4zm-49.6-17.3c0.3-2.2 2.4-3 3.3-2.8 0.7 0.4 1 1.8 0 2.8-1.5 2-3.3 1.7-3.3 0zm40 90.2c-4 1-5.5 1.5-11.5 2.4-7.7 1-19.7 2.1-31.2 3.4l-33.8 2.9c-0.7 0.2-1-0.4-1-1-0.6-6.5-1.2-20.5-1.5-39.5l0.3-23.3c0.6-7.5 0.7-8.7 4.6-9.7 5.1-0.9 7.4-1.4 14.9-1.8l19.5-0.5 41.1-0.5c1.4 0 1.9 0.4 1.9 1.5l-3.3 66.1z'
/>
<path
fill='#23AF58'
d='m38.9 52.4c-1.8 0-4 1.1-4.5 3.3-1 3.9 1 7.6 4.5 7.7 3.8 0 5-3.8 4.7-6.3-0.2-2-2-4.7-4.7-4.7z'
/>
<path
fill='#23AF58'
d='m73.5 53.9c-1.8 0-4.3 1.5-4.4 4.5-0.1 3.2 2 5.3 4.3 5.3 2.5 0 4.2-1.7 4.2-4.8 0-3.2-1.7-4.8-4.1-5z'
/>
<path
fill='#23AF58'
d='m72.1 77.1c-2.7 3.4-7.2 7.4-14.7 8.3-7.3 0.3-13.9-2.9-20-8.5-3.5-3.4-8 0-6.2 2.7 1.7 2.5 6.4 6.6 10.4 8.8 3.5 2 7.3 3.3 13.8 3.5 4.7 0 9.2-0.8 12.7-2.4 2.9-1.1 5-2.8 6-3.8 2.3-2.1 3.8-4.1 3.5-7.3-0.9-2.5-3.6-2.8-5.5-1.3z'
/>
</svg>
)
}
export function CrowdStrikeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 768 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
@@ -4683,9 +4713,16 @@ export function IAMIcon(props: SVGProps<SVGSVGElement>) {
export function IdentityCenterIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
<svg {...props} viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'>
<defs>
<linearGradient x1='0%' y1='100%' x2='100%' y2='0%' id='identityCenterGradient'>
<stop stopColor='#BD0816' offset='0%' />
<stop stopColor='#FF5252' offset='100%' />
</linearGradient>
</defs>
<rect fill='url(#identityCenterGradient)' width='80' height='80' />
<path
d='M13.694,14.8194562 C14.376,14.1374562 14.376,13.0294562 13.694,12.3474562 C13.353,12.0074562 12.906,11.8374562 12.459,11.8374562 C12.01,11.8374562 11.563,12.0074562 11.222,12.3474562 C10.542,13.0284562 10.542,14.1384562 11.222,14.8194562 C11.905,15.5014562 13.013,15.4994562 13.694,14.8194562 M14.718,15.1374562 L18.703,19.1204562 L17.996,19.8274562 L16.868,18.6994562 L15.793,19.7754562 L15.086,19.0684562 L16.161,17.9924562 L14.011,15.8444562 C13.545,16.1654562 13.003,16.3294562 12.458,16.3294562 C11.755,16.3294562 11.051,16.0624562 10.515,15.5264562 C9.445,14.4554562 9.445,12.7124562 10.515,11.6404562 C11.586,10.5714562 13.329,10.5694562 14.401,11.6404562 C15.351,12.5904562 15.455,14.0674562 14.718,15.1374562 M20,12.1014562 C20,14.1684562 18.505,15.0934562 17.023,15.0934562 L17.023,14.0934562 C17.487,14.0934562 19,13.9494562 19,12.1014562 C19,11.0044562 18.353,10.3894562 16.905,10.1084562 C16.68,10.0654562 16.514,9.87545615 16.501,9.64845615 C16.446,8.74445615 15.987,8.11245615 15.384,8.11245615 C15.084,8.11245615 14.854,8.24245615 14.616,8.54645615 C14.506,8.68845615 14.324,8.75945615 14.147,8.73245615 C13.968,8.70545615 13.818,8.58445615 13.755,8.41445615 C13.577,7.94345615 13.211,7.43345615 12.723,6.97745615 C12.231,6.50945615 10.883,5.50745615 8.972,6.27345615 C7.885,6.70545615 7.034,7.94945615 7.034,9.10745615 C7.034,9.23545615 7.043,9.36245615 7.058,9.48845615 C7.061,9.50945615 7.062,9.53045615 7.062,9.55145615 C7.062,9.79945615 6.882,10.0064562 6.645,10.0464562 C5.886,10.2394562 5,10.7454562 5,12.0554562 L5.005,12.2104562 C5.069,13.3254562 6.252,13.9954562 7.358,13.9984562 L8,13.9984562 L8,14.9984562 L7.357,14.9984562 C5.536,14.9944562 4.095,13.8194562 4.006,12.2644562 C4.003,12.1944562 4,12.1244562 4,12.0554562 C4,10.6944562 4.752,9.64845615 6.035,9.18845615 C6.034,9.16145615 6.034,9.13445615 6.034,9.10745615 C6.034,7.54345615 7.138,5.92545615 8.602,5.34345615 C10.298,4.66545615 12.095,5.00345615 13.409,6.24945615 C13.706,6.52745615 14.076,6.92645615 14.372,7.41345615 C14.673,7.21245615 15.008,7.11245615 15.384,7.11245615 C16.257,7.11245615 17.231,7.77145615 17.458,9.20745615 C19.145,9.63245615 20,10.6054562 20,12.1014562'
d='M46.694,46.8194562 C47.376,46.1374562 47.376,45.0294562 46.694,44.3474562 C46.353,44.0074562 45.906,43.8374562 45.459,43.8374562 C45.01,43.8374562 44.563,44.0074562 44.222,44.3474562 C43.542,45.0284562 43.542,46.1384562 44.222,46.8194562 C44.905,47.5014562 46.013,47.4994562 46.694,46.8194562 M47.718,47.1374562 L51.703,51.1204562 L50.996,51.8274562 L49.868,50.6994562 L48.793,51.7754562 L48.086,51.0684562 L49.161,49.9924562 L47.011,47.8444562 C46.545,48.1654562 46.003,48.3294562 45.458,48.3294562 C44.755,48.3294562 44.051,48.0624562 43.515,47.5264562 C42.445,46.4554562 42.445,44.7124562 43.515,43.6404562 C44.586,42.5714562 46.329,42.5694562 47.401,43.6404562 C48.351,44.5904562 48.455,46.0674562 47.718,47.1374562 M53,44.1014562 C53,46.1684562 51.505,47.0934562 50.023,47.0934562 L50.023,46.0934562 C50.487,46.0934562 52,45.9494562 52,44.1014562 C52,43.0044562 51.353,42.3894562 49.905,42.1084562 C49.68,42.0654562 49.514,41.8754562 49.501,41.6484562 C49.446,40.7444562 48.987,40.1124562 48.384,40.1124562 C48.084,40.1124562 47.854,40.2424562 47.616,40.5464562 C47.506,40.6884562 47.324,40.7594562 47.147,40.7324562 C46.968,40.7054562 46.818,40.5844562 46.755,40.4144562 C46.577,39.9434562 46.211,39.4334562 45.723,38.9774562 C45.231,38.5094562 43.883,37.5074562 41.972,38.2734562 C40.885,38.7054562 40.034,39.9494562 40.034,41.1074562 C40.034,41.2354562 40.043,41.3624562 40.058,41.4884562 C40.061,41.5094562 40.062,41.5304562 40.062,41.5514562 C40.062,41.7994562 39.882,42.0064562 39.645,42.0464562 C38.886,42.2394562 38,42.7454562 38,44.0554562 L38.005,44.2104562 C38.069,45.3254562 39.252,45.9954562 40.358,45.9984562 L41,45.9984562 L41,46.9984562 L40.357,46.9984562 C38.536,46.9944562 37.095,45.8194562 37.006,44.2644562 C37.003,44.1944562 37,44.1244562 37,44.0554562 C37,42.6944562 37.752,41.6484562 39.035,41.1884562 C39.034,41.1614562 39.034,41.1344562 39.034,41.1074562 C39.034,39.5434562 40.138,37.9254562 41.602,37.3434562 C43.298,36.6654562 45.095,37.0034562 46.409,38.2494562 C46.706,38.5274562 47.076,38.9264562 47.372,39.4134562 C47.673,39.2124562 48.008,39.1124562 48.384,39.1124562 C49.257,39.1124562 50.231,39.7714562 50.458,41.2074562 C52.145,41.6324562 53,42.6054562 53,44.1014562 M27,53 L27,27 L53,27 L53,34 L51,34 L51,29 L29,29 L29,51 L51,51 L51,46 L53,46 L53,53 Z'
fill='#FFFFFF'
/>
</svg>

View File

@@ -6,6 +6,7 @@ import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AgentMailIcon,
AgentPhoneIcon,
AgiloftIcon,
AhrefsIcon,
AirtableIcon,
@@ -204,6 +205,7 @@ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
agentmail: AgentMailIcon,
agentphone: AgentPhoneIcon,
agiloft: AgiloftIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,

View File

@@ -6,7 +6,7 @@ description: Control how long execution logs, deleted resources, and copilot dat
import { FAQ } from '@/components/ui/faq'
import { Image } from '@/components/ui/image'
Data Retention lets workspace admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. Each workspace in your organization can have its own independent configuration.
Data Retention lets organization owners and admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. The configuration applies to every workspace in the organization.
---
@@ -58,9 +58,9 @@ Each setting is independent. You can configure a short log retention period alon
---
## Per-workspace configuration
## Organization-wide configuration
Retention is configured at the **workspace level**, not organization-wide. Each workspace in your organization can have a different configuration. Changes to one workspace's settings do not affect other workspaces.
Retention is configured at the **organization level**. A single configuration applies to every workspace in the organization — there are no per-workspace overrides.
---
@@ -73,7 +73,7 @@ By default, all three settings are unconfigured — no data is automatically del
<FAQ items={[
{
question: "Who can configure data retention settings?",
answer: "Only workspace admins can configure data retention settings. On Sim Cloud, the workspace must be on an Enterprise plan."
answer: "Only organization owners and admins can configure data retention settings. On Sim Cloud, the organization must be on an Enterprise plan."
},
{
question: "Is deletion immediate once the retention period expires?",
@@ -85,7 +85,7 @@ By default, all three settings are unconfigured — no data is automatically del
},
{
question: "Does the retention period apply to all workspaces in my organization?",
answer: "No. Retention is configured per workspace. Each workspace in your organization can have a different configuration."
answer: "Yes. Retention is configured once per organization and applies to every workspace in the organization."
},
{
question: "What happens if I shorten the retention period?",

View File

@@ -0,0 +1,629 @@
---
title: AgentPhone
description: Provision numbers, send SMS and iMessage, and place voice calls with AgentPhone
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="agentphone"
color="linear-gradient(135deg, #1a1a1a 0%, #0a2a14 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AgentPhone](https://agentphone.to/) is an API-first voice and messaging platform built for AI agents. AgentPhone lets you provision real phone numbers, place outbound AI voice calls, send SMS and iMessage, manage conversations and contacts, and monitor usage — all through a simple REST API designed for programmatic access.
**Why AgentPhone?**
- **Agent-Native Telephony:** Purpose-built for AI agents — provision numbers, place calls, and send messages without carrier contracts or telephony plumbing.
- **Voice + Messaging in One API:** Drive outbound AI voice calls alongside SMS, MMS, and iMessage from the same account and phone numbers.
- **Conversation & Transcript Management:** Every call returns an ordered transcript; every message thread is tracked as a conversation with full history and metadata.
- **Contacts Built In:** Create, search, update, and delete contacts on the account so your agents can reference people by name instead of raw phone numbers.
- **Usage Visibility:** Inspect plan limits, current counts, and daily/monthly aggregation so workflows can stay inside guardrails.
**Using AgentPhone in Sim**
Sim's AgentPhone integration connects your agentic workflows directly to AgentPhone using an API key. With 22 operations spanning numbers, calls, conversations, contacts, and usage, you can build powerful voice and messaging automations without writing backend code.
**Key benefits of using AgentPhone in Sim:**
- **Dynamic number provisioning:** Reserve US or Canadian numbers on the fly — per agent, per customer, or per workflow — and release them when no longer needed.
- **Outbound AI voice calls:** Place calls from an agent with an optional greeting, voice override, or system prompt, and read the full transcript back as structured data once the call completes.
- **Two-way messaging:** Send SMS, MMS, or iMessage, fetch conversation history, and react to incoming iMessages — all from inside your workflow.
- **Contact and metadata management:** Keep an account-level contact list and attach custom JSON metadata to conversations so downstream blocks can branch on state.
- **Operational insight:** Pull current usage stats and daily/monthly breakdowns to monitor consumption and enforce plan limits before making the next call.
Whether you're building an outbound AI voice agent, running automated SMS follow-ups, managing two-way customer conversations, or monitoring phone usage across your organization, AgentPhone in Sim gives you direct, secure access to the full AgentPhone API — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Give your workflow a phone. Provision SMS- and voice-enabled numbers, send messages and tapback reactions, place outbound voice calls, manage conversations and contacts, and track usage — all through a single AgentPhone API key.
## Tools
### `agentphone_create_call`
Initiate an outbound voice call from an AgentPhone agent
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `agentId` | string | Yes | Agent that will handle the call |
| `toNumber` | string | Yes | Phone number to call in E.164 format \(e.g. +14155551234\) |
| `fromNumberId` | string | No | Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent's first assigned number is used. |
| `initialGreeting` | string | No | Optional greeting spoken when the recipient answers |
| `voice` | string | No | Voice ID override for this call \(defaults to the agent's configured voice\) |
| `systemPrompt` | string | No | When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique call identifier |
| `agentId` | string | Agent handling the call |
| `status` | string | Initial call status |
| `toNumber` | string | Destination phone number |
| `fromNumber` | string | Caller ID used for the call |
| `phoneNumberId` | string | ID of the phone number used as caller ID |
| `direction` | string | Call direction \(outbound\) |
| `startedAt` | string | ISO 8601 timestamp |
### `agentphone_create_contact`
Create a new contact in AgentPhone
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `phoneNumber` | string | Yes | Phone number in E.164 format \(e.g. +14155551234\) |
| `name` | string | Yes | Contact's full name |
| `email` | string | No | Contact's email address |
| `notes` | string | No | Freeform notes stored on the contact |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Contact ID |
| `phoneNumber` | string | Phone number in E.164 format |
| `name` | string | Contact name |
| `email` | string | Contact email address |
| `notes` | string | Freeform notes |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 update timestamp |
### `agentphone_create_number`
Provision a new SMS- and voice-enabled phone number
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `country` | string | No | Two-letter country code \(e.g. US, CA\). Defaults to US. |
| `areaCode` | string | No | Preferred area code \(US/CA only, e.g. "415"\). Best-effort — may be ignored if unavailable. |
| `agentId` | string | No | Optionally attach the number to an agent immediately |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique phone number ID |
| `phoneNumber` | string | Provisioned phone number in E.164 format |
| `country` | string | Two-letter country code |
| `status` | string | Number status \(e.g. active\) |
| `type` | string | Number type \(e.g. sms\) |
| `agentId` | string | Agent the number is attached to |
| `createdAt` | string | ISO 8601 timestamp when the number was created |
### `agentphone_delete_contact`
Delete a contact by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `contactId` | string | Yes | Contact ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | ID of the deleted contact |
| `deleted` | boolean | Whether the contact was deleted successfully |
### `agentphone_get_call`
Fetch a call and its full transcript
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `callId` | string | Yes | ID of the call to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Call ID |
| `agentId` | string | Agent that handled the call |
| `phoneNumberId` | string | Phone number ID |
| `phoneNumber` | string | Phone number used for the call |
| `fromNumber` | string | Caller phone number |
| `toNumber` | string | Recipient phone number |
| `direction` | string | inbound or outbound |
| `status` | string | Call status |
| `startedAt` | string | ISO 8601 timestamp |
| `endedAt` | string | ISO 8601 timestamp |
| `durationSeconds` | number | Call duration in seconds |
| `lastTranscriptSnippet` | string | Last transcript snippet |
| `recordingUrl` | string | Recording audio URL |
| `recordingAvailable` | boolean | Whether a recording is available |
| `transcripts` | array | Ordered transcript turns for the call |
| ↳ `id` | string | Transcript turn ID |
| ↳ `transcript` | string | User utterance |
| ↳ `confidence` | number | Speech recognition confidence |
| ↳ `response` | string | Agent response \(when available\) |
| ↳ `createdAt` | string | ISO 8601 timestamp |
### `agentphone_get_call_transcript`
Get the full ordered transcript for a call
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `callId` | string | Yes | ID of the call to retrieve the transcript for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `callId` | string | Call ID |
| `transcript` | array | Ordered transcript turns for the call |
| ↳ `role` | string | Speaker role \(user or agent\) |
| ↳ `content` | string | Turn content |
| ↳ `createdAt` | string | ISO 8601 timestamp |
### `agentphone_get_contact`
Fetch a single contact by ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `contactId` | string | Yes | Contact ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Contact ID |
| `phoneNumber` | string | Phone number in E.164 format |
| `name` | string | Contact name |
| `email` | string | Contact email address |
| `notes` | string | Freeform notes |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 update timestamp |
### `agentphone_get_conversation`
Get a conversation along with its recent messages
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `conversationId` | string | Yes | Conversation ID |
| `messageLimit` | number | No | Number of recent messages to include \(default 50, max 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Conversation ID |
| `agentId` | string | Agent ID |
| `phoneNumberId` | string | Phone number ID |
| `phoneNumber` | string | Phone number |
| `participant` | string | External participant phone number |
| `lastMessageAt` | string | ISO 8601 timestamp |
| `messageCount` | number | Number of messages in the conversation |
| `metadata` | json | Custom metadata stored on the conversation |
| `createdAt` | string | ISO 8601 timestamp |
| `messages` | array | Recent messages in the conversation |
| ↳ `id` | string | Message ID |
| ↳ `body` | string | Message text |
| ↳ `fromNumber` | string | Sender phone number |
| ↳ `toNumber` | string | Recipient phone number |
| ↳ `direction` | string | inbound or outbound |
| ↳ `channel` | string | sms, mms, or imessage |
| ↳ `mediaUrl` | string | Attached media URL |
| ↳ `receivedAt` | string | ISO 8601 timestamp |
### `agentphone_get_conversation_messages`
Get paginated messages for a conversation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `conversationId` | string | Yes | Conversation ID |
| `limit` | number | No | Number of messages to return \(default 50, max 200\) |
| `before` | string | No | Return messages received before this ISO 8601 timestamp |
| `after` | string | No | Return messages received after this ISO 8601 timestamp |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Messages in the conversation |
| ↳ `id` | string | Message ID |
| ↳ `body` | string | Message text |
| ↳ `fromNumber` | string | Sender phone number |
| ↳ `toNumber` | string | Recipient phone number |
| ↳ `direction` | string | inbound or outbound |
| ↳ `channel` | string | sms, mms, or imessage |
| ↳ `mediaUrl` | string | Attached media URL |
| ↳ `receivedAt` | string | ISO 8601 timestamp |
| `hasMore` | boolean | Whether more messages are available |
### `agentphone_get_number_messages`
Fetch messages received on a specific phone number
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `numberId` | string | Yes | ID of the phone number |
| `limit` | number | No | Number of messages to return \(default 50, max 200\) |
| `before` | string | No | Return messages received before this ISO 8601 timestamp |
| `after` | string | No | Return messages received after this ISO 8601 timestamp |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Messages received on the number |
| ↳ `id` | string | Message ID |
| ↳ `from_` | string | Sender phone number \(E.164\) |
| ↳ `to` | string | Recipient phone number \(E.164\) |
| ↳ `body` | string | Message text |
| ↳ `direction` | string | inbound or outbound |
| ↳ `channel` | string | Channel \(sms, mms, etc.\) |
| ↳ `receivedAt` | string | ISO 8601 timestamp |
| `hasMore` | boolean | Whether more messages are available |
### `agentphone_get_usage`
Retrieve current usage statistics for the AgentPhone account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `plan` | json | Plan name and limits \(name, limits: numbers/messagesPerMonth/voiceMinutesPerMonth/maxCallDurationMinutes/concurrentCalls\) |
| `numbers` | json | Phone number usage \(used, limit, remaining\) |
| `stats` | json | Usage stats: totalMessages, messagesLast24h/7d/30d, totalCalls, callsLast24h/7d/30d, totalWebhookDeliveries, successfulWebhookDeliveries, failedWebhookDeliveries |
| `periodStart` | string | Billing period start |
| `periodEnd` | string | Billing period end |
### `agentphone_get_usage_daily`
Get a daily breakdown of usage (messages, calls, webhooks) for the last N days
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `days` | number | No | Number of days to return \(1-365, default 30\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Daily usage entries |
| ↳ `date` | string | Day \(YYYY-MM-DD\) |
| ↳ `messages` | number | Messages that day |
| ↳ `calls` | number | Calls that day |
| ↳ `webhooks` | number | Webhook deliveries that day |
| `days` | number | Number of days returned |
### `agentphone_get_usage_monthly`
Get monthly usage aggregation (messages, calls, webhooks) for the last N months
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `months` | number | No | Number of months to return \(1-24, default 6\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Monthly usage entries |
| ↳ `month` | string | Month \(YYYY-MM\) |
| ↳ `messages` | number | Messages that month |
| ↳ `calls` | number | Calls that month |
| ↳ `webhooks` | number | Webhook deliveries that month |
| `months` | number | Number of months returned |
### `agentphone_list_calls`
List voice calls for this AgentPhone account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `limit` | number | No | Number of results to return \(default 20, max 100\) |
| `offset` | number | No | Number of results to skip \(min 0\) |
| `status` | string | No | Filter by status \(completed, in-progress, failed\) |
| `direction` | string | No | Filter by direction \(inbound, outbound\) |
| `type` | string | No | Filter by call type \(pstn, web\) |
| `search` | string | No | Search by phone number \(matches fromNumber or toNumber\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Calls |
| ↳ `id` | string | Call ID |
| ↳ `agentId` | string | Agent that handled the call |
| ↳ `phoneNumberId` | string | Phone number ID used for the call |
| ↳ `phoneNumber` | string | Phone number used for the call |
| ↳ `fromNumber` | string | Caller phone number |
| ↳ `toNumber` | string | Recipient phone number |
| ↳ `direction` | string | inbound or outbound |
| ↳ `status` | string | Call status |
| ↳ `startedAt` | string | ISO 8601 timestamp |
| ↳ `endedAt` | string | ISO 8601 timestamp |
| ↳ `durationSeconds` | number | Call duration in seconds |
| ↳ `lastTranscriptSnippet` | string | Last transcript snippet |
| ↳ `recordingUrl` | string | Recording audio URL |
| ↳ `recordingAvailable` | boolean | Whether a recording is available |
| `hasMore` | boolean | Whether more results are available |
| `total` | number | Total number of matching calls |
### `agentphone_list_contacts`
List contacts for this AgentPhone account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `search` | string | No | Filter by name or phone number \(case-insensitive contains\) |
| `limit` | number | No | Number of results to return \(default 50\) |
| `offset` | number | No | Number of results to skip \(min 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Contacts |
| ↳ `id` | string | Contact ID |
| ↳ `phoneNumber` | string | Phone number in E.164 format |
| ↳ `name` | string | Contact name |
| ↳ `email` | string | Contact email address |
| ↳ `notes` | string | Freeform notes |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 update timestamp |
| `hasMore` | boolean | Whether more results are available |
| `total` | number | Total number of contacts |
### `agentphone_list_conversations`
List conversations (message threads) for this AgentPhone account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `limit` | number | No | Number of results to return \(default 20, max 100\) |
| `offset` | number | No | Number of results to skip \(min 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Conversations |
| ↳ `id` | string | Conversation ID |
| ↳ `agentId` | string | Agent ID |
| ↳ `phoneNumberId` | string | Phone number ID |
| ↳ `phoneNumber` | string | Phone number |
| ↳ `participant` | string | External participant phone number |
| ↳ `lastMessageAt` | string | ISO 8601 timestamp |
| ↳ `lastMessagePreview` | string | Last message preview |
| ↳ `messageCount` | number | Number of messages in the conversation |
| ↳ `metadata` | json | Custom metadata stored on the conversation |
| ↳ `createdAt` | string | ISO 8601 timestamp |
| `hasMore` | boolean | Whether more results are available |
| `total` | number | Total number of conversations |
### `agentphone_list_numbers`
List all phone numbers provisioned for this AgentPhone account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `limit` | number | No | Number of results to return \(default 20, max 100\) |
| `offset` | number | No | Number of results to skip \(min 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | Phone numbers |
| ↳ `id` | string | Phone number ID |
| ↳ `phoneNumber` | string | Phone number in E.164 format |
| ↳ `country` | string | Two-letter country code |
| ↳ `status` | string | Number status |
| ↳ `type` | string | Number type \(e.g. sms\) |
| ↳ `agentId` | string | Attached agent ID |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| `hasMore` | boolean | Whether more results are available |
| `total` | number | Total number of phone numbers |
### `agentphone_react_to_message`
Send an iMessage tapback reaction to a message (iMessage only)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `messageId` | string | Yes | ID of the message to react to |
| `reaction` | string | Yes | Reaction type: love, like, dislike, laugh, emphasize, or question |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Reaction ID |
| `reactionType` | string | Reaction type applied |
| `messageId` | string | ID of the message that was reacted to |
| `channel` | string | Channel \(imessage\) |
### `agentphone_release_number`
Release (delete) a phone number. This action is irreversible.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `numberId` | string | Yes | ID of the phone number to release |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | ID of the released phone number |
| `released` | boolean | Whether the number was released successfully |
### `agentphone_send_message`
Send an outbound SMS or iMessage from an AgentPhone agent
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `agentId` | string | Yes | Agent sending the message |
| `toNumber` | string | Yes | Recipient phone number in E.164 format \(e.g. +14155551234\) |
| `body` | string | Yes | Message text to send |
| `mediaUrl` | string | No | Optional URL of an image, video, or file to attach |
| `numberId` | string | No | Phone number ID to send from. If omitted, the agent's first assigned number is used. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Message ID |
| `status` | string | Delivery status |
| `channel` | string | sms, mms, or imessage |
| `fromNumber` | string | Sender phone number |
| `toNumber` | string | Recipient phone number |
### `agentphone_update_contact`
Update a contact
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `contactId` | string | Yes | Contact ID |
| `phoneNumber` | string | No | New phone number in E.164 format |
| `name` | string | No | New contact name |
| `email` | string | No | New email address |
| `notes` | string | No | New freeform notes |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Contact ID |
| `phoneNumber` | string | Phone number in E.164 format |
| `name` | string | Contact name |
| `email` | string | Contact email address |
| `notes` | string | Freeform notes |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 update timestamp |
### `agentphone_update_conversation`
Update conversation metadata (stored state). Pass null to clear existing metadata.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | AgentPhone API key |
| `conversationId` | string | Yes | Conversation ID |
| `metadata` | json | No | Custom key-value metadata to store on the conversation. Pass null to clear existing metadata. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Conversation ID |
| `agentId` | string | Agent ID |
| `phoneNumberId` | string | Phone number ID |
| `phoneNumber` | string | Phone number |
| `participant` | string | External participant phone number |
| `lastMessageAt` | string | ISO 8601 timestamp |
| `messageCount` | number | Number of messages |
| `metadata` | json | Custom metadata stored on the conversation |
| `createdAt` | string | ISO 8601 timestamp |
| `messages` | array | Messages in the conversation |
| ↳ `id` | string | Message ID |
| ↳ `body` | string | Message body |
| ↳ `fromNumber` | string | Sender phone number |
| ↳ `toNumber` | string | Recipient phone number |
| ↳ `direction` | string | inbound or outbound |
| ↳ `channel` | string | Channel \(sms, mms, etc.\) |
| ↳ `mediaUrl` | string | Media URL if any |
| ↳ `receivedAt` | string | ISO 8601 timestamp |

View File

@@ -3,6 +3,7 @@
"index",
"a2a",
"agentmail",
"agentphone",
"agiloft",
"ahrefs",
"airtable",

View File

@@ -23,6 +23,10 @@ import { env } from '@/env'
const logger = createLogger('SocketDatabase')
const connectionString = env.DATABASE_URL
/**
* Server-side safety net for runaway queries and abandoned transactions.
* See `packages/db/index.ts` for rationale.
*/
const socketDb = drizzle(
postgres(connectionString, {
prepare: false,
@@ -30,6 +34,9 @@ const socketDb = drizzle(
connect_timeout: 20,
max: 30,
onnotice: () => {},
connection: {
options: '-c statement_timeout=90000 -c idle_in_transaction_session_timeout=90000',
},
}),
{ schema }
)

View File

@@ -6,6 +6,7 @@ import type { ComponentType, SVGProps } from 'react'
import {
A2AIcon,
AgentMailIcon,
AgentPhoneIcon,
AgiloftIcon,
AhrefsIcon,
AirtableIcon,
@@ -204,6 +205,7 @@ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
a2a: A2AIcon,
agentmail: AgentMailIcon,
agentphone: AgentPhoneIcon,
agiloft: AgiloftIcon,
ahrefs: AhrefsIcon,
airtable: AirtableIcon,

View File

@@ -208,6 +208,113 @@
"integrationTypes": ["email", "communication"],
"tags": ["messaging"]
},
{
"type": "agentphone",
"slug": "agentphone",
"name": "AgentPhone",
"description": "Provision numbers, send SMS and iMessage, and place voice calls with AgentPhone",
"longDescription": "Give your workflow a phone. Provision SMS- and voice-enabled numbers, send messages and tapback reactions, place outbound voice calls, manage conversations and contacts, and track usage — all through a single AgentPhone API key.",
"bgColor": "linear-gradient(135deg, #1a1a1a 0%, #0a2a14 100%)",
"iconName": "AgentPhoneIcon",
"docsUrl": "https://docs.sim.ai/tools/agentphone",
"operations": [
{
"name": "Create Number",
"description": "Provision a new SMS- and voice-enabled phone number"
},
{
"name": "List Numbers",
"description": "List all phone numbers provisioned for this AgentPhone account"
},
{
"name": "Release Number",
"description": "Release (delete) a phone number. This action is irreversible."
},
{
"name": "Get Number Messages",
"description": "Fetch messages received on a specific phone number"
},
{
"name": "Create Call",
"description": "Initiate an outbound voice call from an AgentPhone agent"
},
{
"name": "List Calls",
"description": "List voice calls for this AgentPhone account"
},
{
"name": "Get Call",
"description": "Fetch a call and its full transcript"
},
{
"name": "Get Call Transcript",
"description": "Get the full ordered transcript for a call"
},
{
"name": "List Conversations",
"description": "List conversations (message threads) for this AgentPhone account"
},
{
"name": "Get Conversation",
"description": "Get a conversation along with its recent messages"
},
{
"name": "Update Conversation",
"description": "Update conversation metadata (stored state). Pass null to clear existing metadata."
},
{
"name": "Get Conversation Messages",
"description": "Get paginated messages for a conversation"
},
{
"name": "Send Message",
"description": "Send an outbound SMS or iMessage from an AgentPhone agent"
},
{
"name": "React to Message",
"description": "Send an iMessage tapback reaction to a message (iMessage only)"
},
{
"name": "Create Contact",
"description": "Create a new contact in AgentPhone"
},
{
"name": "List Contacts",
"description": "List contacts for this AgentPhone account"
},
{
"name": "Get Contact",
"description": "Fetch a single contact by ID"
},
{
"name": "Update Contact",
"description": "Update a contact"
},
{
"name": "Delete Contact",
"description": "Delete a contact by ID"
},
{
"name": "Get Usage",
"description": "Retrieve current usage statistics for the AgentPhone account"
},
{
"name": "Get Daily Usage",
"description": "Get a daily breakdown of usage (messages, calls, webhooks) for the last N days"
},
{
"name": "Get Monthly Usage",
"description": "Get monthly usage aggregation (messages, calls, webhooks) for the last N months"
}
],
"operationCount": 22,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationTypes": ["communication", "developer-tools"],
"tags": ["messaging", "automation"]
},
{
"type": "agiloft",
"slug": "agiloft",

View File

@@ -0,0 +1,214 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
CLEANUP_CONFIG,
type OrganizationRetentionSettings,
} from '@/lib/billing/cleanup-dispatcher'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('DataRetentionAPI')
const MIN_HOURS = 24
const MAX_HOURS = 43800
const updateRetentionSchema = z.object({
logRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
softDeleteRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
taskCleanupHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
})
function enterpriseDefaults(): OrganizationRetentionSettings {
return {
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
}
}
function normalizeConfigured(
settings: Partial<OrganizationRetentionSettings> | null | undefined
): OrganizationRetentionSettings {
return {
logRetentionHours: settings?.logRetentionHours ?? null,
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
taskCleanupHours: settings?.taskCleanupHours ?? null,
}
}
/**
* GET /api/organizations/[id]/data-retention
* Returns the organization's data retention settings.
* Accessible by any member of the organization.
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const [org] = await db
.select({ dataRetentionSettings: organization.dataRetentionSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
const configured = normalizeConfigured(org.dataRetentionSettings)
const defaults = enterpriseDefaults()
return NextResponse.json({
success: true,
data: {
isEnterprise,
defaults,
configured,
effective: isEnterprise ? configured : defaults,
},
})
}
)
/**
* PUT /api/organizations/[id]/data-retention
* Updates the organization's data retention settings.
* Requires enterprise plan and owner/admin role.
*/
export const PUT = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const body = await request.json()
const parsed = updateRetentionSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}
const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update data retention' },
{ status: 403 }
)
}
if (isBillingEnabled) {
const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
if (!hasEnterprise) {
return NextResponse.json(
{ error: 'Data Retention is available on Enterprise plans only' },
{ status: 403 }
)
}
}
const [currentOrg] = await db
.select({
name: organization.name,
dataRetentionSettings: organization.dataRetentionSettings,
})
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
const merged: OrganizationRetentionSettings = { ...current }
if (parsed.data.logRetentionHours !== undefined) {
merged.logRetentionHours = parsed.data.logRetentionHours
}
if (parsed.data.softDeleteRetentionHours !== undefined) {
merged.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours
}
if (parsed.data.taskCleanupHours !== undefined) {
merged.taskCleanupHours = parsed.data.taskCleanupHours
}
const [updated] = await db
.update(organization)
.set({ dataRetentionSettings: merged, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
.returning({ dataRetentionSettings: organization.dataRetentionSettings })
if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated data retention settings',
metadata: { changes: parsed.data },
request,
})
const configured = normalizeConfigured(updated.dataRetentionSettings)
const defaults = enterpriseDefaults()
return NextResponse.json({
success: true,
data: {
isEnterprise: true,
defaults,
configured,
effective: configured,
},
})
}
)

View File

@@ -260,6 +260,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
archivedAt:
t.archivedAt instanceof Date
? t.archivedAt.toISOString()
: t.archivedAt
? String(t.archivedAt)
: null,
}
}),
totalCount: tables.length,

View File

@@ -1,226 +0,0 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
const logger = createLogger('DataRetentionAPI')
const MIN_HOURS = 24
const MAX_HOURS = 43800 // 5 years
interface RetentionValues {
logRetentionHours: number | null
softDeleteRetentionHours: number | null
taskCleanupHours: number | null
}
function getPlanDefaults(plan: PlanCategory): RetentionValues {
return {
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults[plan],
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults[plan],
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults[plan],
}
}
async function resolveWorkspacePlan(billedAccountUserId: string): Promise<PlanCategory> {
const sub = await getHighestPrioritySubscription(billedAccountUserId)
return getPlanType(sub?.plan)
}
const updateRetentionSchema = z.object({
logRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
softDeleteRetentionHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
taskCleanupHours: z.number().int().min(MIN_HOURS).max(MAX_HOURS).nullable().optional(),
})
/**
* GET /api/workspaces/[id]/data-retention
* Returns the workspace's data retention config including plan defaults and
* whether the workspace is on an enterprise plan.
*/
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: workspaceId } = await params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
}
const [ws] = await db
.select({
logRetentionHours: workspace.logRetentionHours,
softDeleteRetentionHours: workspace.softDeleteRetentionHours,
taskCleanupHours: workspace.taskCleanupHours,
billedAccountUserId: workspace.billedAccountUserId,
})
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const plan = await resolveWorkspacePlan(ws.billedAccountUserId)
const defaults = getPlanDefaults(plan)
const isEnterpriseWorkspace = !isBillingEnabled || plan === 'enterprise'
return NextResponse.json({
success: true,
data: {
plan,
isEnterprise: isEnterpriseWorkspace,
defaults,
configured: {
logRetentionHours: ws.logRetentionHours,
softDeleteRetentionHours: ws.softDeleteRetentionHours,
taskCleanupHours: ws.taskCleanupHours,
},
effective: isEnterpriseWorkspace
? {
logRetentionHours: ws.logRetentionHours,
softDeleteRetentionHours: ws.softDeleteRetentionHours,
taskCleanupHours: ws.taskCleanupHours,
}
: {
logRetentionHours: defaults.logRetentionHours,
softDeleteRetentionHours: defaults.softDeleteRetentionHours,
taskCleanupHours: defaults.taskCleanupHours,
},
},
})
} catch (error) {
logger.error('Failed to get data retention settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
/**
* PUT /api/workspaces/[id]/data-retention
* Updates the workspace's data retention settings.
* Requires admin permission and enterprise plan.
*/
export const PUT = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: workspaceId } = await params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (!billedAccountUserId) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (isBillingEnabled) {
const hasEnterprise = await isEnterprisePlan(billedAccountUserId)
if (!hasEnterprise) {
return NextResponse.json(
{ error: 'Data Retention configuration is available on Enterprise plans only' },
{ status: 403 }
)
}
}
const body = await request.json()
const parsed = updateRetentionSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.logRetentionHours !== undefined) {
updateData.logRetentionHours = parsed.data.logRetentionHours
}
if (parsed.data.softDeleteRetentionHours !== undefined) {
updateData.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours
}
if (parsed.data.taskCleanupHours !== undefined) {
updateData.taskCleanupHours = parsed.data.taskCleanupHours
}
const [updated] = await db
.update(workspace)
.set(updateData)
.where(eq(workspace.id, workspaceId))
.returning({
logRetentionHours: workspace.logRetentionHours,
softDeleteRetentionHours: workspace.softDeleteRetentionHours,
taskCleanupHours: workspace.taskCleanupHours,
})
if (!updated) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: 'Updated data retention settings',
metadata: { changes: parsed.data },
request,
})
const defaults = getPlanDefaults('enterprise')
return NextResponse.json({
success: true,
data: {
plan: 'enterprise' as const,
isEnterprise: true,
defaults,
configured: {
logRetentionHours: updated.logRetentionHours,
softDeleteRetentionHours: updated.softDeleteRetentionHours,
taskCleanupHours: updated.taskCleanupHours,
},
effective: {
logRetentionHours: updated.logRetentionHours,
softDeleteRetentionHours: updated.softDeleteRetentionHours,
taskCleanupHours: updated.taskCleanupHours,
},
},
})
} catch (error) {
logger.error('Failed to update data retention settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)

View File

@@ -97,6 +97,7 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const MIME_TYPE_LABELS: Record<string, string> = {
@@ -249,7 +250,7 @@ export function Files() {
result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy))
}
const col = activeSort?.column ?? 'created'
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
@@ -266,6 +267,9 @@ export function Files() {
case 'created':
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'owner':
cmp = (members?.find((m) => m.userId === a.uploadedBy)?.name ?? '').localeCompare(
members?.find((m) => m.userId === b.uploadedBy)?.name ?? ''
@@ -310,6 +314,7 @@ export function Files() {
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.updatedAt),
},
}
nextCache.set(file.id, { row, file, members })
@@ -875,6 +880,7 @@ export function Files() {
{ id: 'size', label: 'Size' },
{ id: 'type', label: 'Type' },
{ id: 'created', label: 'Created' },
{ id: 'updated', label: 'Last Updated' },
{ id: 'owner', label: 'Owner' },
],
active: activeSort,

View File

@@ -1,16 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { ThinkingBlock } from '../thinking-block'
import { ToolCallItem } from './tool-call-item'
export type AgentGroupItem =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string; startedAt?: number; endedAt?: number }
| { type: 'tool'; data: ToolCallData }
interface AgentGroupProps {
@@ -113,52 +111,117 @@ export function AgentGroup({
{hasItems && (
<Expandable expanded={expanded}>
<ExpandableContent>
<div className='flex flex-col gap-1.5 pt-0.5'>
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
)
}
if (item.type === 'thinking') {
const elapsedMs =
item.startedAt !== undefined && item.endedAt !== undefined
? item.endedAt - item.startedAt
: undefined
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
return (
<div key={`thinking-${idx}`} className='pl-6'>
<ThinkingBlock
content={item.content}
isActive={
isStreaming && idx === items.length - 1 && item.endedAt === undefined
}
isStreaming={isStreaming}
startedAt={item.startedAt}
endedAt={item.endedAt}
<BoundedViewport isStreaming={isStreaming}>
<div className='flex flex-col gap-1.5 py-0.5'>
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
</div>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 font-base text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
>
{item.content.trim()}
</span>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 font-base text-[var(--text-secondary)] text-small'
>
{item.content.trim()}
</span>
)
})}
</div>
})}
</div>
</BoundedViewport>
</ExpandableContent>
</Expandable>
)}
</div>
)
}
interface BoundedViewportProps {
children: React.ReactNode
isStreaming: boolean
}
const BOTTOM_STICK_THRESHOLD_PX = 8
function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
const ref = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const [hasOverflow, setHasOverflow] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
// (wheel back down or dragging scrollbar) re-attaches it.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) stickToBottomRef.current = false
}
const handleScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
}
el.addEventListener('wheel', handleWheel, { passive: true })
el.addEventListener('scroll', handleScroll, { passive: true })
return () => {
el.removeEventListener('wheel', handleWheel)
el.removeEventListener('scroll', handleScroll)
}
}, [])
useLayoutEffect(() => {
const el = ref.current
if (el) {
const next = el.scrollHeight > el.clientHeight
setHasOverflow((prev) => (prev === next ? prev : next))
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (!isStreaming) return
const tick = () => {
const node = ref.current
if (!node || !stickToBottomRef.current) {
rafRef.current = null
return
}
const target = node.scrollHeight - node.clientHeight
const gap = target - node.scrollTop
if (gap < 1) {
rafRef.current = null
return
}
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
rafRef.current = window.requestAnimationFrame(tick)
}
rafRef.current = window.requestAnimationFrame(tick)
return () => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
})
return (
<div className='relative'>
<div ref={ref} className={cn('max-h-[110px] overflow-y-auto pr-2', hasOverflow && 'py-1')}>
{children}
</div>
{hasOverflow && (
<>
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-gradient-to-b from-[var(--bg)] to-transparent' />
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-gradient-to-t from-[var(--bg)] to-transparent' />
</>
)}
</div>
)
}

View File

@@ -164,7 +164,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block.type === 'subagent_text') {
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
if (!block.content || !group) continue
group.isDelegating = false
const lastItem = group.items[group.items.length - 1]
@@ -176,24 +176,6 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
continue
}
if (block.type === 'subagent_thinking') {
if (!block.content || !group) continue
group.isDelegating = false
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'thinking' && lastItem.endedAt === undefined) {
lastItem.content += block.content
if (block.endedAt !== undefined) lastItem.endedAt = block.endedAt
} else {
group.items.push({
type: 'thinking',
content: block.content,
startedAt: block.timestamp,
endedAt: block.endedAt,
})
}
continue
}
if (block.type === 'thinking') {
if (!block.content?.trim()) continue
if (group) {

View File

@@ -124,6 +124,7 @@ export const ResourceContent = memo(function ResourceContent({
type,
uploadedBy: '',
uploadedAt: STREAMING_EPOCH,
updatedAt: STREAMING_EPOCH,
}
}, [workspaceId, streamFileName])

View File

@@ -3001,7 +3001,12 @@ export function useChat(
...timing,
}
}
return { type: block.type, content: block.content, ...timing }
return {
type: block.type,
content: block.content,
...(block.subagent ? { lane: 'subagent' } : {}),
...timing,
}
})
if (storedBlocks.length > 0) {

View File

@@ -216,7 +216,7 @@ export function Knowledge() {
result = result.filter((kb) => ownerFilter.includes(kb.userId))
}
const col = activeSort?.column ?? 'created'
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0

View File

@@ -750,27 +750,43 @@ export function Table({
const colIndex = cols.findIndex((c) => c.name === columnName)
if (colIndex === -1) return
const column = cols[colIndex]
if (column.type === 'boolean') return
const host = containerRef.current ?? document.body
const currentRows = rowsRef.current
let maxWidth = COL_WIDTH_MIN
const measure = document.createElement('span')
measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap'
document.body.appendChild(measure)
measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;top:-9999px'
host.appendChild(measure)
try {
measure.className = 'font-medium text-small'
measure.textContent = columnName
maxWidth = Math.max(maxWidth, measure.offsetWidth + 57)
maxWidth = Math.max(maxWidth, measure.getBoundingClientRect().width + 57)
measure.className = 'text-small'
for (const row of currentRows) {
const val = row.data[columnName]
if (val == null) continue
measure.textContent = String(val)
maxWidth = Math.max(maxWidth, measure.offsetWidth + 17)
let text: string
if (column.type === 'json') {
try {
text = JSON.stringify(val)
} catch {
text = String(val)
}
} else if (column.type === 'date') {
text = storageToDisplay(String(val))
} else {
text = String(val)
}
measure.textContent = text
maxWidth = Math.max(maxWidth, measure.getBoundingClientRect().width + 17)
}
} finally {
document.body.removeChild(measure)
host.removeChild(measure)
}
const newWidth = Math.min(Math.ceil(maxWidth), 600)

View File

@@ -123,7 +123,7 @@ export function Tables() {
if (ownerFilter.length > 0) {
result = result.filter((t) => ownerFilter.includes(t.createdBy))
}
const col = activeSort?.column ?? 'created'
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { jobExecutionLogs, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { and, inArray, lt } from 'drizzle-orm'
@@ -112,6 +112,63 @@ async function cleanupTier(
return results
}
interface JobLogCleanupResults {
deleted: number
deleteFailed: number
}
async function cleanupJobExecutionLogsTier(
workspaceIds: string[],
retentionDate: Date,
label: string
): Promise<JobLogCleanupResults> {
const results: JobLogCleanupResults = { deleted: 0, deleteFailed: 0 }
if (workspaceIds.length === 0) return results
let batchesProcessed = 0
let hasMore = true
while (hasMore && batchesProcessed < MAX_BATCHES_PER_TIER) {
const batch = await db
.select({ id: jobExecutionLogs.id })
.from(jobExecutionLogs)
.where(
and(
inArray(jobExecutionLogs.workspaceId, workspaceIds),
lt(jobExecutionLogs.startedAt, retentionDate)
)
)
.limit(BATCH_SIZE)
if (batch.length === 0) {
hasMore = false
break
}
const logIds = batch.map((log) => log.id)
try {
const deleted = await db
.delete(jobExecutionLogs)
.where(inArray(jobExecutionLogs.id, logIds))
.returning({ id: jobExecutionLogs.id })
results.deleted += deleted.length
} catch (deleteError) {
results.deleteFailed += logIds.length
logger.error(`Batch delete failed for ${label} (job_execution_logs):`, { deleteError })
}
batchesProcessed++
hasMore = batch.length === BATCH_SIZE
logger.info(
`[${label}] job_execution_logs batch ${batchesProcessed}: ${batch.length} rows processed`
)
}
return results
}
export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void> {
const startTime = Date.now()
@@ -135,7 +192,12 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void>
const results = await cleanupTier(workspaceIds, retentionDate, label)
logger.info(
`[${label}] Result: ${results.deleted} deleted, ${results.deleteFailed} failed out of ${results.total} candidates`
`[${label}] workflow_execution_logs: ${results.deleted} deleted, ${results.deleteFailed} failed out of ${results.total} candidates`
)
const jobLogResults = await cleanupJobExecutionLogsTier(workspaceIds, retentionDate, label)
logger.info(
`[${label}] job_execution_logs: ${jobLogResults.deleted} deleted, ${jobLogResults.deleteFailed} failed`
)
// Snapshot cleanup runs only on the free job to avoid running it N times for N enterprise workspaces.

View File

@@ -0,0 +1,751 @@
import { toError } from '@sim/utils/errors'
import { AgentPhoneIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
const CONVERSATION_OPS = [
'get_conversation',
'update_conversation',
'get_conversation_messages',
] as const
const CONTACT_ID_OPS = ['get_contact', 'update_contact', 'delete_contact'] as const
const CALL_ID_OPS = ['get_call', 'get_call_transcript'] as const
const NUMBER_ID_OPS = ['release_number', 'get_number_messages'] as const
const OFFSET_LIMIT_OPS = [
'list_numbers',
'list_calls',
'list_conversations',
'list_contacts',
] as const
const CURSOR_MESSAGE_OPS = ['get_number_messages', 'get_conversation_messages'] as const
export const AgentPhoneBlock: BlockConfig = {
type: 'agentphone',
name: 'AgentPhone',
description: 'Provision numbers, send SMS and iMessage, and place voice calls with AgentPhone',
longDescription:
'Give your workflow a phone. Provision SMS- and voice-enabled numbers, send messages and tapback reactions, place outbound voice calls, manage conversations and contacts, and track usage — all through a single AgentPhone API key.',
docsLink: 'https://docs.sim.ai/tools/agentphone',
category: 'tools',
integrationType: IntegrationType.Communication,
tags: ['messaging', 'automation'],
bgColor: 'linear-gradient(135deg, #1a1a1a 0%, #0a2a14 100%)',
icon: AgentPhoneIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Number', id: 'create_number' },
{ label: 'List Numbers', id: 'list_numbers' },
{ label: 'Release Number', id: 'release_number' },
{ label: 'Get Number Messages', id: 'get_number_messages' },
{ label: 'Create Call', id: 'create_call' },
{ label: 'List Calls', id: 'list_calls' },
{ label: 'Get Call', id: 'get_call' },
{ label: 'Get Call Transcript', id: 'get_call_transcript' },
{ label: 'List Conversations', id: 'list_conversations' },
{ label: 'Get Conversation', id: 'get_conversation' },
{ label: 'Update Conversation', id: 'update_conversation' },
{ label: 'Get Conversation Messages', id: 'get_conversation_messages' },
{ label: 'Send Message', id: 'send_message' },
{ label: 'React to Message', id: 'react_to_message' },
{ label: 'Create Contact', id: 'create_contact' },
{ label: 'List Contacts', id: 'list_contacts' },
{ label: 'Get Contact', id: 'get_contact' },
{ label: 'Update Contact', id: 'update_contact' },
{ label: 'Delete Contact', id: 'delete_contact' },
{ label: 'Get Usage', id: 'get_usage' },
{ label: 'Get Daily Usage', id: 'get_usage_daily' },
{ label: 'Get Monthly Usage', id: 'get_usage_monthly' },
],
value: () => 'create_number',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your AgentPhone API key',
required: true,
password: true,
},
// Numbers - Create
{
id: 'country',
title: 'Country',
type: 'dropdown',
options: [
{ label: 'United States (US)', id: 'US' },
{ label: 'Canada (CA)', id: 'CA' },
],
value: () => 'US',
condition: { field: 'operation', value: 'create_number' },
},
{
id: 'areaCode',
title: 'Area Code',
type: 'short-input',
placeholder: '415 (US/CA only, optional)',
condition: { field: 'operation', value: 'create_number' },
mode: 'advanced',
},
{
id: 'attachAgentId',
title: 'Agent ID',
type: 'short-input',
placeholder: 'Attach the number to this agent (optional)',
condition: { field: 'operation', value: 'create_number' },
mode: 'advanced',
},
// Numbers - shared numberId
{
id: 'numberId',
title: 'Phone Number ID',
type: 'short-input',
placeholder: 'num_xxx',
condition: { field: 'operation', value: [...NUMBER_ID_OPS] },
required: { field: 'operation', value: [...NUMBER_ID_OPS] },
},
// Calls - Create
{
id: 'callAgentId',
title: 'Agent ID',
type: 'short-input',
placeholder: 'agt_xxx',
condition: { field: 'operation', value: 'create_call' },
required: { field: 'operation', value: 'create_call' },
},
{
id: 'toNumberCall',
title: 'To Phone Number',
type: 'short-input',
placeholder: '+14155551234',
condition: { field: 'operation', value: 'create_call' },
required: { field: 'operation', value: 'create_call' },
},
{
id: 'fromNumberId',
title: 'From Phone Number ID',
type: 'short-input',
placeholder: "num_xxx (defaults to the agent's first number)",
condition: { field: 'operation', value: 'create_call' },
mode: 'advanced',
},
{
id: 'initialGreeting',
title: 'Initial Greeting',
type: 'long-input',
placeholder: 'Hi, this is Acme Corp calling about your recent order.',
condition: { field: 'operation', value: 'create_call' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt:
'Generate a short, natural-sounding phone greeting for an AI agent to speak when the recipient answers. Keep it under 2 sentences. Return ONLY the greeting text - no explanations, no extra text.',
placeholder: 'Describe the greeting tone and purpose...',
},
},
{
id: 'voice',
title: 'Voice',
type: 'short-input',
placeholder: "Polly.Amy (defaults to the agent's configured voice)",
condition: { field: 'operation', value: 'create_call' },
mode: 'advanced',
},
{
id: 'systemPrompt',
title: 'System Prompt',
type: 'long-input',
placeholder: 'You are a friendly support agent from Acme Corp...',
condition: { field: 'operation', value: 'create_call' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt:
'Generate a concise system prompt for an AI phone agent. Describe personality, objective, and constraints clearly. Return ONLY the prompt text - no explanations, no extra text.',
placeholder: 'Describe the agent persona and objective...',
},
},
// Calls - shared callId
{
id: 'callId',
title: 'Call ID',
type: 'short-input',
placeholder: 'call_xxx',
condition: { field: 'operation', value: [...CALL_ID_OPS] },
required: { field: 'operation', value: [...CALL_ID_OPS] },
},
// Calls - list filters
{
id: 'callsStatus',
title: 'Status Filter',
type: 'dropdown',
options: [
{ label: 'Any', id: '' },
{ label: 'Completed', id: 'completed' },
{ label: 'In Progress', id: 'in-progress' },
{ label: 'Failed', id: 'failed' },
],
value: () => '',
condition: { field: 'operation', value: 'list_calls' },
mode: 'advanced',
},
{
id: 'callsDirection',
title: 'Direction Filter',
type: 'dropdown',
options: [
{ label: 'Any', id: '' },
{ label: 'Inbound', id: 'inbound' },
{ label: 'Outbound', id: 'outbound' },
],
value: () => '',
condition: { field: 'operation', value: 'list_calls' },
mode: 'advanced',
},
{
id: 'callsType',
title: 'Type Filter',
type: 'dropdown',
options: [
{ label: 'Any', id: '' },
{ label: 'PSTN', id: 'pstn' },
{ label: 'Web', id: 'web' },
],
value: () => '',
condition: { field: 'operation', value: 'list_calls' },
mode: 'advanced',
},
{
id: 'callsSearch',
title: 'Search',
type: 'short-input',
placeholder: 'Phone number to match against fromNumber or toNumber',
condition: { field: 'operation', value: 'list_calls' },
mode: 'advanced',
},
// Conversations - shared conversationId
{
id: 'conversationId',
title: 'Conversation ID',
type: 'short-input',
placeholder: 'conv_xxx',
condition: { field: 'operation', value: [...CONVERSATION_OPS] },
required: { field: 'operation', value: [...CONVERSATION_OPS] },
},
{
id: 'messageLimit',
title: 'Message Limit',
type: 'short-input',
placeholder: '50 (max 100)',
condition: { field: 'operation', value: 'get_conversation' },
mode: 'advanced',
},
{
id: 'metadata',
title: 'Metadata',
type: 'long-input',
placeholder: '{"customerName":"Jane","orderId":"ORD-12345"}',
condition: { field: 'operation', value: 'update_conversation' },
wandConfig: {
enabled: true,
prompt:
'Generate a valid JSON object to store on the conversation as metadata. Use flat string/number values where possible. Return ONLY the JSON object - no explanations, no extra text.',
placeholder: 'Describe the fields to store (customer name, order ID, topic)...',
},
},
// Messages - Send
{
id: 'sendAgentId',
title: 'Agent ID',
type: 'short-input',
placeholder: 'agt_xxx',
condition: { field: 'operation', value: 'send_message' },
required: { field: 'operation', value: 'send_message' },
},
{
id: 'toNumberMessage',
title: 'To Phone Number',
type: 'short-input',
placeholder: '+14155551234',
condition: { field: 'operation', value: 'send_message' },
required: { field: 'operation', value: 'send_message' },
},
{
id: 'messageBody',
title: 'Message Body',
type: 'long-input',
placeholder: 'Hi! Your appointment is confirmed for tomorrow at 3 PM.',
condition: { field: 'operation', value: 'send_message' },
required: { field: 'operation', value: 'send_message' },
wandConfig: {
enabled: true,
prompt:
'Generate a friendly, concise SMS or iMessage body. Keep it under 160 characters where possible. Return ONLY the message text - no explanations, no extra text.',
placeholder: 'Describe the message purpose and tone...',
},
},
{
id: 'mediaUrl',
title: 'Media URL',
type: 'short-input',
placeholder: 'https://cdn.example.com/image.png (optional)',
condition: { field: 'operation', value: 'send_message' },
mode: 'advanced',
},
{
id: 'sendNumberId',
title: 'From Phone Number ID',
type: 'short-input',
placeholder: "num_xxx (defaults to the agent's first number)",
condition: { field: 'operation', value: 'send_message' },
mode: 'advanced',
},
// Messages - React
{
id: 'messageId',
title: 'Message ID',
type: 'short-input',
placeholder: 'msg_xxx',
condition: { field: 'operation', value: 'react_to_message' },
required: { field: 'operation', value: 'react_to_message' },
},
{
id: 'reaction',
title: 'Reaction',
type: 'dropdown',
options: [
{ label: 'Love', id: 'love' },
{ label: 'Like', id: 'like' },
{ label: 'Dislike', id: 'dislike' },
{ label: 'Laugh', id: 'laugh' },
{ label: 'Emphasize', id: 'emphasize' },
{ label: 'Question', id: 'question' },
],
value: () => 'love',
condition: { field: 'operation', value: 'react_to_message' },
required: { field: 'operation', value: 'react_to_message' },
},
// Contacts - Create / Update shared fields
{
id: 'contactPhoneNumber',
title: 'Phone Number',
type: 'short-input',
placeholder: '+14155551234',
condition: { field: 'operation', value: ['create_contact', 'update_contact'] },
required: { field: 'operation', value: 'create_contact' },
},
{
id: 'contactName',
title: 'Name',
type: 'short-input',
placeholder: 'Alice Johnson',
condition: { field: 'operation', value: ['create_contact', 'update_contact'] },
required: { field: 'operation', value: 'create_contact' },
},
{
id: 'contactEmail',
title: 'Email',
type: 'short-input',
placeholder: 'alice@example.com (optional)',
condition: { field: 'operation', value: ['create_contact', 'update_contact'] },
mode: 'advanced',
},
{
id: 'contactNotes',
title: 'Notes',
type: 'long-input',
placeholder: 'Freeform notes (optional)',
condition: { field: 'operation', value: ['create_contact', 'update_contact'] },
mode: 'advanced',
},
// Contacts - shared contactId
{
id: 'contactId',
title: 'Contact ID',
type: 'short-input',
placeholder: 'contact_xxx',
condition: { field: 'operation', value: [...CONTACT_ID_OPS] },
required: { field: 'operation', value: [...CONTACT_ID_OPS] },
},
{
id: 'contactsSearch',
title: 'Search',
type: 'short-input',
placeholder: 'Filter by name or phone number',
condition: { field: 'operation', value: 'list_contacts' },
mode: 'advanced',
},
// Pagination - offset/limit for list_* operations
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: '20 (max 100)',
condition: { field: 'operation', value: [...OFFSET_LIMIT_OPS] },
mode: 'advanced',
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: [...OFFSET_LIMIT_OPS] },
mode: 'advanced',
},
// Pagination - limit/before/after for message endpoints
{
id: 'messagesLimit',
title: 'Limit',
type: 'short-input',
placeholder: '50 (max 200)',
condition: { field: 'operation', value: [...CURSOR_MESSAGE_OPS] },
mode: 'advanced',
},
{
id: 'before',
title: 'Before',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g. 2025-01-15T12:00:00Z)',
condition: { field: 'operation', value: [...CURSOR_MESSAGE_OPS] },
mode: 'advanced',
wandConfig: {
enabled: true,
generationType: 'timestamp',
prompt:
'Convert the natural-language time description to an ISO 8601 timestamp (UTC). Return ONLY the timestamp - no explanations, no extra text.',
placeholder: 'Describe the cutoff time (e.g. 2 hours ago)...',
},
},
{
id: 'after',
title: 'After',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g. 2025-01-15T12:00:00Z)',
condition: { field: 'operation', value: [...CURSOR_MESSAGE_OPS] },
mode: 'advanced',
wandConfig: {
enabled: true,
generationType: 'timestamp',
prompt:
'Convert the natural-language time description to an ISO 8601 timestamp (UTC). Return ONLY the timestamp - no explanations, no extra text.',
placeholder: 'Describe the start time (e.g. 2 hours ago)...',
},
},
// Usage
{
id: 'usageDays',
title: 'Days',
type: 'short-input',
placeholder: '30 (1-365)',
condition: { field: 'operation', value: 'get_usage_daily' },
mode: 'advanced',
},
{
id: 'usageMonths',
title: 'Months',
type: 'short-input',
placeholder: '6 (1-24)',
condition: { field: 'operation', value: 'get_usage_monthly' },
mode: 'advanced',
},
],
tools: {
access: [
'agentphone_create_call',
'agentphone_create_contact',
'agentphone_create_number',
'agentphone_delete_contact',
'agentphone_get_call',
'agentphone_get_call_transcript',
'agentphone_get_contact',
'agentphone_get_conversation',
'agentphone_get_conversation_messages',
'agentphone_get_number_messages',
'agentphone_get_usage',
'agentphone_get_usage_daily',
'agentphone_get_usage_monthly',
'agentphone_list_calls',
'agentphone_list_contacts',
'agentphone_list_conversations',
'agentphone_list_numbers',
'agentphone_react_to_message',
'agentphone_release_number',
'agentphone_send_message',
'agentphone_update_contact',
'agentphone_update_conversation',
],
config: {
tool: (params) => `agentphone_${params.operation || 'create_number'}`,
params: (params) => {
const {
operation,
attachAgentId,
callAgentId,
toNumberCall,
toNumberMessage,
sendAgentId,
sendNumberId,
messageBody,
contactPhoneNumber,
contactName,
contactEmail,
contactNotes,
contactsSearch,
callsStatus,
callsDirection,
callsType,
callsSearch,
messageLimit,
messagesLimit,
limit,
offset,
usageDays,
usageMonths,
metadata,
...rest
} = params
if (operation === 'create_number' && attachAgentId) {
rest.agentId = attachAgentId
}
if (operation === 'create_call') {
if (callAgentId) rest.agentId = callAgentId
if (toNumberCall) rest.toNumber = toNumberCall
}
if (operation === 'send_message') {
if (sendAgentId) rest.agentId = sendAgentId
if (toNumberMessage) rest.toNumber = toNumberMessage
if (sendNumberId) rest.numberId = sendNumberId
if (messageBody !== undefined) rest.body = messageBody
}
if (['create_contact', 'update_contact'].includes(operation as string)) {
if (contactPhoneNumber) rest.phoneNumber = contactPhoneNumber
if (contactName) rest.name = contactName
if (contactEmail) rest.email = contactEmail
if (contactNotes) rest.notes = contactNotes
}
if (operation === 'list_contacts' && contactsSearch !== undefined) {
rest.search = contactsSearch
}
if (operation === 'list_calls') {
if (callsStatus) rest.status = callsStatus
if (callsDirection) rest.direction = callsDirection
if (callsType) rest.type = callsType
if (callsSearch) rest.search = callsSearch
}
const toFiniteNumber = (value: unknown, field: string): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid numeric value for ${field}: ${String(value)}`)
}
return parsed
}
if (operation === 'get_conversation' && messageLimit !== undefined && messageLimit !== '') {
rest.messageLimit = toFiniteNumber(messageLimit, 'Message Limit')
}
if (
(operation === 'get_number_messages' || operation === 'get_conversation_messages') &&
messagesLimit !== undefined &&
messagesLimit !== ''
) {
rest.limit = toFiniteNumber(messagesLimit, 'Limit')
}
if (
OFFSET_LIMIT_OPS.includes(operation as (typeof OFFSET_LIMIT_OPS)[number]) &&
limit !== undefined &&
limit !== ''
) {
rest.limit = toFiniteNumber(limit, 'Limit')
}
if (
OFFSET_LIMIT_OPS.includes(operation as (typeof OFFSET_LIMIT_OPS)[number]) &&
offset !== undefined &&
offset !== ''
) {
rest.offset = toFiniteNumber(offset, 'Offset')
}
if (operation === 'get_usage_daily' && usageDays !== undefined && usageDays !== '') {
rest.days = toFiniteNumber(usageDays, 'Days')
}
if (operation === 'get_usage_monthly' && usageMonths !== undefined && usageMonths !== '') {
rest.months = toFiniteNumber(usageMonths, 'Months')
}
if (operation === 'update_conversation' && metadata !== undefined) {
if (metadata === null || metadata === '') {
rest.metadata = null
} else if (typeof metadata === 'string') {
try {
rest.metadata = JSON.parse(metadata)
} catch (error) {
throw new Error(`Invalid JSON for Metadata: ${toError(error).message}`)
}
} else {
rest.metadata = metadata
}
}
return rest
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'AgentPhone API key' },
country: { type: 'string', description: 'Country code (US or CA)' },
areaCode: { type: 'string', description: 'Preferred area code (US/CA only)' },
attachAgentId: { type: 'string', description: 'Agent ID to attach on number provisioning' },
numberId: { type: 'string', description: 'Phone number ID' },
callAgentId: { type: 'string', description: 'Agent ID to place the call from' },
toNumberCall: { type: 'string', description: 'Destination phone number for the call' },
fromNumberId: { type: 'string', description: 'Phone number ID to use as caller ID' },
initialGreeting: { type: 'string', description: 'Optional initial greeting' },
voice: { type: 'string', description: 'Voice override' },
systemPrompt: { type: 'string', description: 'System prompt for built-in LLM' },
callId: { type: 'string', description: 'Call ID' },
callsStatus: { type: 'string', description: 'Filter calls by status' },
callsDirection: { type: 'string', description: 'Filter calls by direction' },
callsType: { type: 'string', description: 'Filter calls by type (pstn or web)' },
callsSearch: { type: 'string', description: 'Search calls by phone number' },
conversationId: { type: 'string', description: 'Conversation ID' },
messageLimit: { type: 'string', description: 'Number of messages to include' },
metadata: { type: 'string', description: 'JSON metadata object to store on conversation' },
sendAgentId: { type: 'string', description: 'Agent ID sending the message' },
toNumberMessage: { type: 'string', description: 'Recipient phone number' },
messageBody: { type: 'string', description: 'Message body' },
mediaUrl: { type: 'string', description: 'Media URL to attach' },
sendNumberId: { type: 'string', description: 'Phone number ID to send from' },
messageId: { type: 'string', description: 'Message ID' },
reaction: { type: 'string', description: 'Reaction type' },
contactPhoneNumber: { type: 'string', description: 'Contact phone number' },
contactName: { type: 'string', description: 'Contact name' },
contactEmail: { type: 'string', description: 'Contact email' },
contactNotes: { type: 'string', description: 'Contact notes' },
contactId: { type: 'string', description: 'Contact ID' },
contactsSearch: { type: 'string', description: 'Contact search filter' },
limit: { type: 'string', description: 'Pagination limit' },
offset: { type: 'string', description: 'Pagination offset' },
messagesLimit: { type: 'string', description: 'Messages pagination limit' },
before: { type: 'string', description: 'Cursor: ISO 8601 upper bound' },
after: { type: 'string', description: 'Cursor: ISO 8601 lower bound' },
usageDays: { type: 'string', description: 'Number of days for daily usage' },
usageMonths: { type: 'string', description: 'Number of months for monthly usage' },
},
outputs: {
id: { type: 'string', description: 'ID of the primary resource returned' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
country: { type: 'string', description: 'Country code' },
status: { type: 'string', description: 'Status field (varies by operation)' },
type: { type: 'string', description: 'Resource type (e.g. sms)' },
agentId: { type: 'string', description: 'Agent ID associated with the resource' },
phoneNumberId: { type: 'string', description: 'Phone number ID' },
fromNumber: { type: 'string', description: 'Originating phone number' },
toNumber: { type: 'string', description: 'Destination phone number' },
direction: { type: 'string', description: 'inbound or outbound' },
startedAt: { type: 'string', description: 'ISO 8601 start timestamp' },
endedAt: { type: 'string', description: 'ISO 8601 end timestamp' },
durationSeconds: { type: 'number', description: 'Call duration in seconds' },
lastTranscriptSnippet: { type: 'string', description: 'Last transcript snippet' },
recordingUrl: { type: 'string', description: 'Recording audio URL' },
recordingAvailable: { type: 'boolean', description: 'Whether a recording is available' },
transcripts: {
type: 'json',
description:
'Ordered transcript turns on call detail: [{id, transcript, confidence, response, createdAt}]',
},
transcript: {
type: 'json',
description: 'Flat transcript entries from the transcript endpoint: [{role, content}]',
},
callId: { type: 'string', description: 'Call ID' },
channel: { type: 'string', description: 'Message channel: sms, mms, or imessage' },
from_: { type: 'string', description: 'Sender phone number on a number message' },
body: { type: 'string', description: 'Message body text' },
mediaUrl: { type: 'string', description: 'Attached media URL' },
receivedAt: { type: 'string', description: 'ISO 8601 timestamp' },
participant: { type: 'string', description: 'External participant phone number' },
lastMessageAt: { type: 'string', description: 'ISO 8601 timestamp' },
lastMessagePreview: {
type: 'string',
description: 'Last message preview (list_conversations only)',
},
messageCount: { type: 'number', description: 'Number of messages in a conversation' },
metadata: { type: 'json', description: 'Custom metadata stored on a conversation' },
messages: {
type: 'json',
description:
'Conversation messages: [{id, body, fromNumber, toNumber, direction, channel, mediaUrl, receivedAt}]',
},
reactionType: { type: 'string', description: 'Reaction type applied' },
messageId: { type: 'string', description: 'Message ID' },
name: { type: 'string', description: 'Contact name' },
email: { type: 'string', description: 'Contact email' },
notes: { type: 'string', description: 'Contact notes' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
data: {
type: 'json',
description: 'Array of items returned by list operations (shape varies by operation)',
},
hasMore: { type: 'boolean', description: 'Whether more results are available' },
total: { type: 'number', description: 'Total number of matching items' },
released: { type: 'boolean', description: 'Whether a phone number was released' },
deleted: { type: 'boolean', description: 'Whether a contact was deleted' },
plan: {
type: 'json',
description:
'Usage plan (name, limits: numbers/messagesPerMonth/voiceMinutesPerMonth/maxCallDurationMinutes/concurrentCalls)',
},
numbers: {
type: 'json',
description: 'Number usage breakdown (used, limit, remaining)',
},
stats: {
type: 'json',
description:
'Usage stats: totalMessages/messagesLast{24h,7d,30d}, totalCalls/callsLast{24h,7d,30d}, webhook delivery counts',
},
periodStart: { type: 'string', description: 'Usage period start' },
periodEnd: { type: 'string', description: 'Usage period end' },
days: { type: 'number', description: 'Days returned for daily usage' },
months: { type: 'number', description: 'Months returned for monthly usage' },
},
}

View File

@@ -1,6 +1,7 @@
import { A2ABlock } from '@/blocks/blocks/a2a'
import { AgentBlock } from '@/blocks/blocks/agent'
import { AgentMailBlock } from '@/blocks/blocks/agentmail'
import { AgentPhoneBlock } from '@/blocks/blocks/agentphone'
import { AgiloftBlock } from '@/blocks/blocks/agiloft'
import { AhrefsBlock } from '@/blocks/blocks/ahrefs'
import { AirtableBlock } from '@/blocks/blocks/airtable'
@@ -233,6 +234,7 @@ export const registry: Record<string, BlockConfig> = {
a2a: A2ABlock,
agent: AgentBlock,
agentmail: AgentMailBlock,
agentphone: AgentPhoneBlock,
agiloft: AgiloftBlock,
ahrefs: AhrefsBlock,
airtable: AirtableBlock,

View File

@@ -28,6 +28,36 @@ export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function AgentPhoneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 150 150' xmlns='http://www.w3.org/2000/svg'>
<path
fill='#23AF58'
stroke='#007F3F'
strokeWidth='0.15'
strokeMiterlimit='10'
d='m139.6 53.3c-1.4-2.3-4.9-3.3-7.6-4.8-2.7-1.3-4.2-2.4-5.7-3.6-1.9-1-2.5-2.7-3.3-3.2s-2.7-1.4-4.5 1.3c-2 2.7-4.5 6.6-6.6 11.1-2.3 5.4-6.3 14.9-6.3 18.9 0.5 4.9 3.1 4.6 6.1 7.2 2.5 2.1 2.8 5.8 1.5 12.5-1.3 6.6-4 12.8-7.8 19.2-3.3 5.1-5.8 8.7-10 9.1-5.3 0.5-12.5-3.1-16.8-5.6-1-0.6-2.5-0.9-3.8-0.2-1.3 0.5-2.2 1.6-3.2 3.3-1.5 2.5-4.6 7.7-5.8 12.2-0.5 3 0 6.4 2.9 9 1.4 1.2 2.8 2.5 4.4 3.4 5 2.8 9.6 4.5 16.5 4.9 5.3 0.2 9.3-1 13.4-3.1 2.4-1.3 6.6-4.2 9.6-7.3l1.1-1.2c2.8-3.1 8.8-10 11.6-14.5 2.3-3.5 4.8-7.4 6.9-12.3 2.9-6.7 4.4-14 5-17.9 1.2-7 2.4-17.5 3.4-31.1 0.1-4.3-0.3-6.1-1-7.3zm-4.5 6.7c-0.5 9.5-1.9 23.3-3.1 30.1-0.9 4.5-2.4 9.6-3.8 13.4-1.1 2.6-3.1 7-5.6 10.8-3.4 5.3-8.4 11.6-12 15.8-6.4 6.6-10.2 9.6-14.2 10.8-2.2 0.9-3.8 1.2-7 1.2-3.4-0.1-8-0.7-11.3-2.2-3-1.2-7-4-6.9-6.8 0.4-3.2 3.3-9.6 5.2-11.9 0.2-0.3 0.5-0.3 0.7-0.2 2.5 1.1 6 3.2 9.6 4.5 2.4 0.9 4.8 1.4 7.3 1.4 3.9 0 6.7-1.2 9.5-3.2 5.6-4.6 9-10.8 12.1-17.5 2-4.3 4.1-11.6 4.4-18.3 0.1-4.9-1.1-8.9-4.5-12.2-1.1-0.7-3-2.1-3-2.8 0-4.2 3.9-13 8.9-22.9 0.2-0.7 0.5-1 1.1-0.7 1.1 0.6 3 1.4 4.6 2.4 2.1 1 5.4 2.4 7.1 3.9 0.9 0.4 1 3 0.9 4.4z'
/>
<path
fill='#23AF58'
d='m104.7 27.8c-1.3-1.5-3.3-1.3-6.2-1.5l-1.9 0.2-7-0.2-31.5 0.2 1.5-9.3c2-1.1 5.1-3.5 5.8-6.3 1-2.8 0.2-5.9-2-7.4-2.3-1.9-5.8-2.4-9.3-0.8-1.6 1-4.7 3.4-5.4 6.9-0.8 4.1 2.4 6.7 4.7 7.9l-1.5 9.1-17.2 0.9c-12.3 1.1-16.3 1.2-20.6 4.3-2 1.3-3 4.5-3.4 9.8-0.6 11.3-0.7 18.7-0.6 28.3 0.4 11.2 0 36.6 3 39.8l-1.2 0.3c-3.8 0.6-4 6.2-0.5 6.6l15.5-1 69.7-7.6c2.5-0.4 4.3-0.9 4.6-4.3l3.7-71.5c0-1.9 0.2-3.6-0.2-4.4zm-49.6-17.3c0.3-2.2 2.4-3 3.3-2.8 0.7 0.4 1 1.8 0 2.8-1.5 2-3.3 1.7-3.3 0zm40 90.2c-4 1-5.5 1.5-11.5 2.4-7.7 1-19.7 2.1-31.2 3.4l-33.8 2.9c-0.7 0.2-1-0.4-1-1-0.6-6.5-1.2-20.5-1.5-39.5l0.3-23.3c0.6-7.5 0.7-8.7 4.6-9.7 5.1-0.9 7.4-1.4 14.9-1.8l19.5-0.5 41.1-0.5c1.4 0 1.9 0.4 1.9 1.5l-3.3 66.1z'
/>
<path
fill='#23AF58'
d='m38.9 52.4c-1.8 0-4 1.1-4.5 3.3-1 3.9 1 7.6 4.5 7.7 3.8 0 5-3.8 4.7-6.3-0.2-2-2-4.7-4.7-4.7z'
/>
<path
fill='#23AF58'
d='m73.5 53.9c-1.8 0-4.3 1.5-4.4 4.5-0.1 3.2 2 5.3 4.3 5.3 2.5 0 4.2-1.7 4.2-4.8 0-3.2-1.7-4.8-4.1-5z'
/>
<path
fill='#23AF58'
d='m72.1 77.1c-2.7 3.4-7.2 7.4-14.7 8.3-7.3 0.3-13.9-2.9-20-8.5-3.5-3.4-8 0-6.2 2.7 1.7 2.5 6.4 6.6 10.4 8.8 3.5 2 7.3 3.3 13.8 3.5 4.7 0 9.2-0.8 12.7-2.4 2.9-1.1 5-2.8 6-3.8 2.3-2.1 3.8-4.1 3.5-7.3-0.9-2.5-3.6-2.8-5.5-1.3z'
/>
</svg>
)
}
export function CrowdStrikeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 768 500' fill='none' xmlns='http://www.w3.org/2000/svg'>

View File

@@ -3,16 +3,17 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { Button, Combobox, toast } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SettingRow } from '@/ee/components/setting-row'
import { DataRetentionSkeleton } from '@/ee/data-retention/components/data-retention-skeleton'
import {
useUpdateWorkspaceRetention,
useWorkspaceRetention,
useOrganizationRetention,
useUpdateOrganizationRetention,
} from '@/ee/data-retention/hooks/data-retention'
import { useOrganizations } from '@/hooks/queries/organization'
const logger = createLogger('DataRetentionSettings')
@@ -68,12 +69,18 @@ function RetentionSelect({ value, onChange }: RetentionSelectProps) {
}
export function DataRetentionSettings() {
const params = useParams<{ workspaceId: string }>()
const workspaceId = params.workspaceId
const { data: session, isPending: sessionPending } = useSession()
const { data: orgsData, isLoading: orgsLoading } = useOrganizations()
const { data, isLoading } = useWorkspaceRetention(workspaceId)
const { canAdmin } = useUserPermissionsContext()
const updateMutation = useUpdateWorkspaceRetention()
const activeOrganization = orgsData?.activeOrganization
const orgId = activeOrganization?.id
const { data, isLoading: retentionLoading } = useOrganizationRetention(orgId)
const updateMutation = useUpdateOrganizationRetention()
const userEmail = session?.user?.email
const userRole = getUserRole(activeOrganization, userEmail)
const canManage = userRole === 'owner' || userRole === 'admin'
const [logDays, setLogDays] = useState('')
const [softDeleteDays, setSoftDeleteDays] = useState('')
@@ -103,9 +110,10 @@ export function DataRetentionSettings() {
taskCleanupDays !== savedTaskCleanupDays
async function handleSave() {
if (!orgId) return
try {
await updateMutation.mutateAsync({
workspaceId,
orgId,
settings: {
logRetentionHours: daysToHours(logDays),
softDeleteRetentionHours: daysToHours(softDeleteDays),
@@ -123,7 +131,17 @@ export function DataRetentionSettings() {
}
}
if (isLoading) return <DataRetentionSkeleton />
if (sessionPending || orgsLoading || (orgId && retentionLoading)) {
return <DataRetentionSkeleton />
}
if (!orgId) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Data retention is configured per organization. Join or create an organization to continue.
</div>
)
}
if (!data) {
return (
@@ -141,10 +159,10 @@ export function DataRetentionSettings() {
)
}
if (!canAdmin) {
if (!canManage) {
return (
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>
Only workspace admins can configure data retention settings.
Only organization owners and admins can configure data retention settings.
</div>
)
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
export interface RetentionValues {
logRetentionHours: number | null
@@ -10,7 +9,6 @@ export interface RetentionValues {
}
export interface DataRetentionResponse {
plan: PlanCategory
isEnterprise: boolean
defaults: RetentionValues
configured: RetentionValues
@@ -19,14 +17,14 @@ export interface DataRetentionResponse {
export const dataRetentionKeys = {
all: ['dataRetention'] as const,
settings: (workspaceId: string) => [...dataRetentionKeys.all, 'settings', workspaceId] as const,
settings: (orgId: string) => [...dataRetentionKeys.all, 'settings', orgId] as const,
}
async function fetchDataRetention(
workspaceId: string,
orgId: string,
signal?: AbortSignal
): Promise<DataRetentionResponse> {
const response = await fetch(`/api/workspaces/${workspaceId}/data-retention`, { signal })
const response = await fetch(`/api/organizations/${orgId}/data-retention`, { signal })
if (!response.ok) {
const error = await response.json().catch(() => ({}))
@@ -37,26 +35,26 @@ async function fetchDataRetention(
return data as DataRetentionResponse
}
export function useWorkspaceRetention(workspaceId: string | undefined) {
export function useOrganizationRetention(orgId: string | undefined) {
return useQuery({
queryKey: dataRetentionKeys.settings(workspaceId ?? ''),
queryFn: ({ signal }) => fetchDataRetention(workspaceId as string, signal),
enabled: Boolean(workspaceId),
queryKey: dataRetentionKeys.settings(orgId ?? ''),
queryFn: ({ signal }) => fetchDataRetention(orgId as string, signal),
enabled: Boolean(orgId),
staleTime: 60 * 1000,
})
}
interface UpdateRetentionVariables {
workspaceId: string
orgId: string
settings: Partial<RetentionValues>
}
export function useUpdateWorkspaceRetention() {
export function useUpdateOrganizationRetention() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, settings }: UpdateRetentionVariables) => {
const response = await fetch(`/api/workspaces/${workspaceId}/data-retention`, {
mutationFn: async ({ orgId, settings }: UpdateRetentionVariables) => {
const response = await fetch(`/api/organizations/${orgId}/data-retention`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
@@ -70,8 +68,8 @@ export function useUpdateWorkspaceRetention() {
const { data } = await response.json()
return data as DataRetentionResponse
},
onSettled: (_data, _error, { workspaceId }) => {
queryClient.invalidateQueries({ queryKey: dataRetentionKeys.settings(workspaceId) })
onSettled: (_data, _error, { orgId }) => {
queryClient.invalidateQueries({ queryKey: dataRetentionKeys.settings(orgId) })
},
})
}

View File

@@ -1,9 +1,9 @@
import { db } from '@sim/db'
import { subscription, workspace } from '@sim/db/schema'
import { organization, subscription, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import { type PlanCategory, sqlIsPaid, sqlIsPro, sqlIsTeam } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getJobQueue } from '@/lib/core/async-jobs'
@@ -16,11 +16,15 @@ const BATCH_TRIGGER_CHUNK_SIZE = 1000
export type CleanupJobType = 'cleanup-logs' | 'cleanup-soft-deletes' | 'cleanup-tasks'
export type WorkspaceRetentionColumn =
export type OrganizationRetentionKey =
| 'logRetentionHours'
| 'softDeleteRetentionHours'
| 'taskCleanupHours'
export type OrganizationRetentionSettings = {
[K in OrganizationRetentionKey]: number | null
}
export type NonEnterprisePlan = Exclude<PlanCategory, 'enterprise'>
const NON_ENTERPRISE_PLANS = ['free', 'pro', 'team'] as const satisfies readonly NonEnterprisePlan[]
@@ -30,35 +34,36 @@ export type CleanupJobPayload =
| { plan: 'enterprise'; workspaceId: string }
interface CleanupJobConfig {
column: WorkspaceRetentionColumn
key: OrganizationRetentionKey
defaults: Record<PlanCategory, number | null>
}
const DAY = 24
/**
* Single source of truth for cleanup retention: which workspace column each job
* type inspects, and the default retention (in hours) per plan. Enterprise is
* always `null` here — enterprise tenants must set their own value per workspace.
* Single source of truth for cleanup retention: which key each job type reads
* from `organization.dataRetentionSettings`, and the default retention (in
* hours) per plan. Enterprise is always `null` here — enterprise orgs must
* set their own value.
*/
export const CLEANUP_CONFIG = {
'cleanup-logs': {
column: 'logRetentionHours',
key: 'logRetentionHours',
defaults: { free: 30 * DAY, pro: null, team: null, enterprise: null },
},
'cleanup-soft-deletes': {
column: 'softDeleteRetentionHours',
key: 'softDeleteRetentionHours',
defaults: { free: 30 * DAY, pro: 90 * DAY, team: 90 * DAY, enterprise: null },
},
'cleanup-tasks': {
column: 'taskCleanupHours',
key: 'taskCleanupHours',
defaults: { free: null, pro: null, team: null, enterprise: null },
},
} as const satisfies Record<CleanupJobType, CleanupJobConfig>
/**
* Bulk-lookup workspace IDs for a non-enterprise plan category. Enterprise is
* per-workspace (has explicit opt-in retention), so it's not handled here.
* per-workspace (routed through the owning organization's retention config).
*/
export async function resolveWorkspaceIdsForPlan(plan: NonEnterprisePlan): Promise<string[]> {
if (plan === 'free') {
@@ -105,8 +110,8 @@ export interface ResolvedCleanupScope {
/**
* Translate a queued cleanup payload into a concrete cleanup scope: the set of
* workspaces and the retention cutoff to apply. Returns `null` when the plan
* has no retention configured (default is null, or the enterprise workspace
* has not opted in).
* has no retention configured (default is null, or the enterprise org has not
* set this key).
*/
export async function resolveCleanupScope(
jobType: CleanupJobType,
@@ -121,17 +126,19 @@ export async function resolveCleanupScope(
return { workspaceIds, retentionHours, label: payload.plan }
}
const [ws] = await db
.select({ hours: workspace[config.column] })
const [row] = await db
.select({ settings: organization.dataRetentionSettings })
.from(workspace)
.innerJoin(organization, eq(organization.id, workspace.organizationId))
.where(eq(workspace.id, payload.workspaceId))
.limit(1)
if (ws?.hours == null) return null
const hours = row?.settings?.[config.key]
if (hours == null) return null
return {
workspaceIds: [payload.workspaceId],
retentionHours: ws.hours,
retentionHours: hours,
label: `enterprise/${payload.workspaceId}`,
}
}
@@ -189,7 +196,8 @@ async function runInlineIfNeeded(
* Dispatcher: enqueue cleanup jobs driven by `CLEANUP_CONFIG`.
*
* - One job per non-enterprise plan with a non-null default
* - One enterprise job per workspace with a non-NULL retention value in the column
* - One enterprise job per workspace whose owning organization has a non-null
* retention value for this job's key
*
* Uses Trigger.dev batchTrigger when available, otherwise parallel enqueue via
* the JobQueueBackend abstraction. On the database backend (no external worker),
@@ -211,30 +219,34 @@ export async function dispatchCleanupJobs(
await runInlineIfNeeded(jobQueue, jobType, jobId, payload)
}
// Enterprise: query workspaces with non-NULL retention column. The JOIN can
// match multiple subscription rows per workspace (e.g. active + past_due both
// in ENTITLED_SUBSCRIPTION_STATUSES) — groupBy dedupes to one row per workspace
// so we don't dispatch the same cleanup job twice.
const retentionCol = workspace[config.column]
// Enterprise: workspaces whose owning org is on an active enterprise sub and
// has a non-NULL value for this job's retention key. groupBy dedupes in case
// multiple entitled subscription rows exist for the same org.
const enterpriseRows = await db
.select({ id: workspace.id })
.from(workspace)
.innerJoin(organization, eq(organization.id, workspace.organizationId))
.innerJoin(
subscription,
and(
eq(subscription.referenceId, workspace.billedAccountUserId),
eq(subscription.referenceId, organization.id),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
eq(subscription.plan, 'enterprise')
)
)
.where(and(isNull(workspace.archivedAt), isNotNull(retentionCol)))
.where(
and(
isNull(workspace.archivedAt),
isNotNull(sql`${organization.dataRetentionSettings}->>${config.key}`)
)
)
.groupBy(workspace.id)
const enterpriseCount = enterpriseRows.length
const planLabels = plansWithDefaults.join('+') || 'none'
logger.info(
`[${jobType}] Dispatching: plans=[${planLabels}] + ${enterpriseCount} enterprise jobs (column: ${config.column})`
`[${jobType}] Dispatching: plans=[${planLabels}] + ${enterpriseCount} enterprise jobs (key: ${config.key})`
)
if (enterpriseCount === 0) {

View File

@@ -29,6 +29,7 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
updatedAt: row.updatedAt,
storageContext: 'mothership' as const,
}
}

View File

@@ -23,6 +23,7 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
updatedAt: row.updatedAt,
storageContext: 'mothership',
}
}

View File

@@ -45,6 +45,7 @@ export interface WorkspaceFileRecord {
uploadedBy: string
deletedAt?: Date | null
uploadedAt: Date
updatedAt: Date
/** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */
storageContext?: 'workspace' | 'mothership'
}
@@ -375,6 +376,7 @@ export async function getWorkspaceFileByName(
uploadedBy: file.userId,
deletedAt: file.deletedAt,
uploadedAt: file.uploadedAt,
updatedAt: file.updatedAt,
}
}
@@ -423,6 +425,7 @@ export async function listWorkspaceFiles(
uploadedBy: file.userId,
deletedAt: file.deletedAt,
uploadedAt: file.uploadedAt,
updatedAt: file.updatedAt,
}))
} catch (error) {
logger.error(`Failed to list workspace files for ${workspaceId}:`, error)
@@ -560,6 +563,7 @@ export async function getWorkspaceFile(
uploadedBy: file.userId,
deletedAt: file.deletedAt,
uploadedAt: file.uploadedAt,
updatedAt: file.updatedAt,
}
} catch (error) {
logger.error(`Failed to get workspace file ${fileId}:`, error)
@@ -638,7 +642,7 @@ export async function updateWorkspaceFileContent(
await db
.update(workspaceFiles)
.set({ size: content.length, contentType: nextContentType })
.set({ size: content.length, contentType: nextContentType, updatedAt: new Date() })
.where(
and(
eq(workspaceFiles.id, fileId),
@@ -707,7 +711,7 @@ export async function renameWorkspaceFile(
try {
updated = await db
.update(workspaceFiles)
.set({ originalName: trimmedName })
.set({ originalName: trimmedName, updatedAt: new Date() })
.where(
and(
eq(workspaceFiles.id, fileId),
@@ -807,7 +811,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string):
await db
.update(workspaceFiles)
.set({ deletedAt: null, originalName: newName })
.set({ deletedAt: null, originalName: newName, updatedAt: new Date() })
.where(
and(
eq(workspaceFiles.id, fileId),

View File

@@ -0,0 +1,132 @@
import type {
AgentPhoneCreateCallParams,
AgentPhoneCreateCallResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneCreateCallTool: ToolConfig<
AgentPhoneCreateCallParams,
AgentPhoneCreateCallResult
> = {
id: 'agentphone_create_call',
name: 'Create Outbound Call',
description: 'Initiate an outbound voice call from an AgentPhone agent',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
agentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Agent that will handle the call',
},
toNumber: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Phone number to call in E.164 format (e.g. +14155551234)',
},
fromNumberId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent's first assigned number is used.",
},
initialGreeting: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional greeting spoken when the recipient answers',
},
voice: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: "Voice ID override for this call (defaults to the agent's configured voice)",
},
systemPrompt: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook',
},
},
request: {
url: 'https://api.agentphone.to/v1/calls',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {
agentId: params.agentId,
toNumber: params.toNumber,
}
if (params.fromNumberId) body.fromNumberId = params.fromNumberId
if (params.initialGreeting) body.initialGreeting = params.initialGreeting
if (params.voice) body.voice = params.voice
if (params.systemPrompt) body.systemPrompt = params.systemPrompt
return body
},
},
transformResponse: async (response): Promise<AgentPhoneCreateCallResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to create call',
output: {
id: '',
agentId: null,
status: null,
toNumber: null,
fromNumber: null,
phoneNumberId: null,
direction: null,
startedAt: null,
},
}
}
return {
success: true,
output: {
id: data.id ?? data.callId ?? '',
agentId: data.agentId ?? null,
status: data.status ?? null,
toNumber: data.toNumber ?? null,
fromNumber: data.fromNumber ?? null,
phoneNumberId: data.phoneNumberId ?? null,
direction: data.direction ?? null,
startedAt: data.startedAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Unique call identifier' },
agentId: { type: 'string', description: 'Agent handling the call', optional: true },
status: { type: 'string', description: 'Initial call status', optional: true },
toNumber: { type: 'string', description: 'Destination phone number', optional: true },
fromNumber: { type: 'string', description: 'Caller ID used for the call', optional: true },
phoneNumberId: {
type: 'string',
description: 'ID of the phone number used as caller ID',
optional: true,
},
direction: { type: 'string', description: 'Call direction (outbound)', optional: true },
startedAt: { type: 'string', description: 'ISO 8601 timestamp', optional: true },
},
}

View File

@@ -0,0 +1,109 @@
import type {
AgentPhoneCreateContactParams,
AgentPhoneCreateContactResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneCreateContactTool: ToolConfig<
AgentPhoneCreateContactParams,
AgentPhoneCreateContactResult
> = {
id: 'agentphone_create_contact',
name: 'Create Contact',
description: 'Create a new contact in AgentPhone',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
phoneNumber: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Phone number in E.164 format (e.g. +14155551234)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: "Contact's full name",
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: "Contact's email address",
},
notes: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Freeform notes stored on the contact',
},
},
request: {
url: 'https://api.agentphone.to/v1/contacts',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {
phoneNumber: params.phoneNumber,
name: params.name,
}
if (params.email) body.email = params.email
if (params.notes) body.notes = params.notes
return body
},
},
transformResponse: async (response): Promise<AgentPhoneCreateContactResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to create contact',
output: {
id: '',
phoneNumber: '',
name: '',
email: null,
notes: null,
createdAt: '',
updatedAt: '',
},
}
}
return {
success: true,
output: {
id: data.id ?? '',
phoneNumber: data.phoneNumber ?? '',
name: data.name ?? '',
email: data.email ?? null,
notes: data.notes ?? null,
createdAt: data.createdAt ?? '',
updatedAt: data.updatedAt ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Contact ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
name: { type: 'string', description: 'Contact name' },
email: { type: 'string', description: 'Contact email address', optional: true },
notes: { type: 'string', description: 'Freeform notes', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
},
}

View File

@@ -0,0 +1,106 @@
import type {
AgentPhoneCreateNumberParams,
AgentPhoneCreateNumberResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneCreateNumberTool: ToolConfig<
AgentPhoneCreateNumberParams,
AgentPhoneCreateNumberResult
> = {
id: 'agentphone_create_number',
name: 'Create Phone Number',
description: 'Provision a new SMS- and voice-enabled phone number',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
country: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Two-letter country code (e.g. US, CA). Defaults to US.',
},
areaCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Preferred area code (US/CA only, e.g. "415"). Best-effort — may be ignored if unavailable.',
},
agentId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optionally attach the number to an agent immediately',
},
},
request: {
url: 'https://api.agentphone.to/v1/numbers',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.country) body.country = params.country
if (params.areaCode) body.areaCode = params.areaCode
if (params.agentId) body.agentId = params.agentId
return body
},
},
transformResponse: async (response): Promise<AgentPhoneCreateNumberResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to create phone number',
output: {
id: '',
phoneNumber: '',
country: '',
status: '',
type: '',
agentId: null,
createdAt: '',
},
}
}
return {
success: true,
output: {
id: data.id ?? '',
phoneNumber: data.phoneNumber ?? '',
country: data.country ?? '',
status: data.status ?? '',
type: data.type ?? '',
agentId: data.agentId ?? null,
createdAt: data.createdAt ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Unique phone number ID' },
phoneNumber: { type: 'string', description: 'Provisioned phone number in E.164 format' },
country: { type: 'string', description: 'Two-letter country code' },
status: { type: 'string', description: 'Number status (e.g. active)' },
type: { type: 'string', description: 'Number type (e.g. sms)', optional: true },
agentId: {
type: 'string',
description: 'Agent the number is attached to',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 timestamp when the number was created' },
},
}

View File

@@ -0,0 +1,67 @@
import type {
AgentPhoneDeleteContactParams,
AgentPhoneDeleteContactResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneDeleteContactTool: ToolConfig<
AgentPhoneDeleteContactParams,
AgentPhoneDeleteContactResult
> = {
id: 'agentphone_delete_contact',
name: 'Delete Contact',
description: 'Delete a contact by ID',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
contactId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Contact ID',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/contacts/${params.contactId.trim()}`,
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response, params): Promise<AgentPhoneDeleteContactResult> => {
const contactId = params?.contactId?.trim() ?? ''
if (!response.ok) {
let errorMessage = 'Failed to delete contact'
try {
const data = await response.json()
errorMessage = data?.detail?.[0]?.msg ?? data?.message ?? errorMessage
} catch {
// Response body may be empty; ignore parse failures.
}
return {
success: false,
error: errorMessage,
output: { id: contactId, deleted: false },
}
}
return {
success: true,
output: { id: contactId, deleted: true },
}
},
outputs: {
id: { type: 'string', description: 'ID of the deleted contact' },
deleted: { type: 'boolean', description: 'Whether the contact was deleted successfully' },
},
}

View File

@@ -0,0 +1,146 @@
import type {
AgentPhoneGetCallParams,
AgentPhoneGetCallResult,
AgentPhoneTranscriptTurn,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetCallTool: ToolConfig<AgentPhoneGetCallParams, AgentPhoneGetCallResult> = {
id: 'agentphone_get_call',
name: 'Get Call',
description: 'Fetch a call and its full transcript',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
callId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the call to retrieve',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/calls/${params.callId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetCallResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch call',
output: {
id: '',
agentId: null,
phoneNumberId: null,
phoneNumber: null,
fromNumber: '',
toNumber: '',
direction: '',
status: '',
startedAt: null,
endedAt: null,
durationSeconds: null,
lastTranscriptSnippet: null,
recordingUrl: null,
recordingAvailable: null,
transcripts: [],
},
}
}
const transcripts: AgentPhoneTranscriptTurn[] = (data.transcripts ?? []).map(
(turn: Record<string, unknown>) => ({
id: (turn.id as string) ?? '',
transcript: (turn.transcript as string) ?? '',
confidence: (turn.confidence as number | null) ?? null,
response: (turn.response as string | null) ?? null,
createdAt: (turn.createdAt as string) ?? '',
})
)
return {
success: true,
output: {
id: data.id ?? '',
agentId: data.agentId ?? null,
phoneNumberId: data.phoneNumberId ?? null,
phoneNumber: data.phoneNumber ?? null,
fromNumber: data.fromNumber ?? '',
toNumber: data.toNumber ?? '',
direction: data.direction ?? '',
status: data.status ?? '',
startedAt: data.startedAt ?? null,
endedAt: data.endedAt ?? null,
durationSeconds: data.durationSeconds ?? null,
lastTranscriptSnippet: data.lastTranscriptSnippet ?? null,
recordingUrl: data.recordingUrl ?? null,
recordingAvailable: data.recordingAvailable ?? null,
transcripts,
},
}
},
outputs: {
id: { type: 'string', description: 'Call ID' },
agentId: { type: 'string', description: 'Agent that handled the call', optional: true },
phoneNumberId: { type: 'string', description: 'Phone number ID', optional: true },
phoneNumber: {
type: 'string',
description: 'Phone number used for the call',
optional: true,
},
fromNumber: { type: 'string', description: 'Caller phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
direction: { type: 'string', description: 'inbound or outbound', optional: true },
status: { type: 'string', description: 'Call status' },
startedAt: { type: 'string', description: 'ISO 8601 timestamp', optional: true },
endedAt: { type: 'string', description: 'ISO 8601 timestamp', optional: true },
durationSeconds: { type: 'number', description: 'Call duration in seconds', optional: true },
lastTranscriptSnippet: {
type: 'string',
description: 'Last transcript snippet',
optional: true,
},
recordingUrl: { type: 'string', description: 'Recording audio URL', optional: true },
recordingAvailable: {
type: 'boolean',
description: 'Whether a recording is available',
optional: true,
},
transcripts: {
type: 'array',
description: 'Ordered transcript turns for the call',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Transcript turn ID' },
transcript: { type: 'string', description: 'User utterance' },
confidence: {
type: 'number',
description: 'Speech recognition confidence',
optional: true,
},
response: {
type: 'string',
description: 'Agent response (when available)',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
},
}

View File

@@ -0,0 +1,94 @@
import type {
AgentPhoneGetCallTranscriptParams,
AgentPhoneGetCallTranscriptResult,
AgentPhoneTranscriptEntry,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetCallTranscriptTool: ToolConfig<
AgentPhoneGetCallTranscriptParams,
AgentPhoneGetCallTranscriptResult
> = {
id: 'agentphone_get_call_transcript',
name: 'Get Call Transcript',
description: 'Get the full ordered transcript for a call',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
callId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the call to retrieve the transcript for',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/calls/${params.callId.trim()}/transcript`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response, params): Promise<AgentPhoneGetCallTranscriptResult> => {
const data = await response.json()
const callId = params?.callId?.trim() ?? ''
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch transcript',
output: { callId, transcript: [] },
}
}
const rawTurns = Array.isArray(data?.transcript)
? data.transcript
: Array.isArray(data)
? data
: []
const transcript: AgentPhoneTranscriptEntry[] = rawTurns.map(
(turn: Record<string, unknown>) => ({
role: (turn.role as string) ?? '',
content: (turn.content as string) ?? '',
createdAt: (turn.createdAt as string) ?? (turn.created_at as string) ?? null,
})
)
return {
success: true,
output: { callId: data?.callId ?? callId, transcript },
}
},
outputs: {
callId: { type: 'string', description: 'Call ID' },
transcript: {
type: 'array',
description: 'Ordered transcript turns for the call',
items: {
type: 'object',
properties: {
role: {
type: 'string',
description: 'Speaker role (user or agent)',
},
content: { type: 'string', description: 'Turn content' },
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp',
optional: true,
},
},
},
},
},
}

View File

@@ -0,0 +1,81 @@
import type {
AgentPhoneGetContactParams,
AgentPhoneGetContactResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetContactTool: ToolConfig<
AgentPhoneGetContactParams,
AgentPhoneGetContactResult
> = {
id: 'agentphone_get_contact',
name: 'Get Contact',
description: 'Fetch a single contact by ID',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
contactId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Contact ID',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/contacts/${params.contactId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetContactResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch contact',
output: {
id: '',
phoneNumber: '',
name: '',
email: null,
notes: null,
createdAt: '',
updatedAt: '',
},
}
}
return {
success: true,
output: {
id: data.id ?? '',
phoneNumber: data.phoneNumber ?? '',
name: data.name ?? '',
email: data.email ?? null,
notes: data.notes ?? null,
createdAt: data.createdAt ?? '',
updatedAt: data.updatedAt ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Contact ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
name: { type: 'string', description: 'Contact name' },
email: { type: 'string', description: 'Contact email address', optional: true },
notes: { type: 'string', description: 'Freeform notes', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
},
}

View File

@@ -0,0 +1,137 @@
import type {
AgentPhoneConversationMessage,
AgentPhoneGetConversationParams,
AgentPhoneGetConversationResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetConversationTool: ToolConfig<
AgentPhoneGetConversationParams,
AgentPhoneGetConversationResult
> = {
id: 'agentphone_get_conversation',
name: 'Get Conversation',
description: 'Get a conversation along with its recent messages',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
conversationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Conversation ID',
},
messageLimit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of recent messages to include (default 50, max 100)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.messageLimit === 'number') {
query.set('message_limit', String(params.messageLimit))
}
const qs = query.toString()
return `https://api.agentphone.to/v1/conversations/${params.conversationId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetConversationResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch conversation',
output: {
id: '',
agentId: null,
phoneNumberId: '',
phoneNumber: '',
participant: '',
lastMessageAt: '',
messageCount: 0,
metadata: null,
createdAt: '',
messages: [],
},
}
}
const messages: AgentPhoneConversationMessage[] = (data.messages ?? []).map(
(msg: Record<string, unknown>) => ({
id: (msg.id as string) ?? '',
body: (msg.body as string) ?? '',
fromNumber: (msg.fromNumber as string) ?? '',
toNumber: (msg.toNumber as string) ?? '',
direction: (msg.direction as string) ?? '',
channel: (msg.channel as string | null) ?? null,
mediaUrl: (msg.mediaUrl as string | null) ?? null,
receivedAt: (msg.receivedAt as string) ?? '',
})
)
return {
success: true,
output: {
id: data.id ?? '',
agentId: data.agentId ?? null,
phoneNumberId: data.phoneNumberId ?? '',
phoneNumber: data.phoneNumber ?? '',
participant: data.participant ?? '',
lastMessageAt: data.lastMessageAt ?? '',
messageCount: data.messageCount ?? 0,
metadata: data.metadata ?? null,
createdAt: data.createdAt ?? '',
messages,
},
}
},
outputs: {
id: { type: 'string', description: 'Conversation ID' },
agentId: { type: 'string', description: 'Agent ID', optional: true },
phoneNumberId: { type: 'string', description: 'Phone number ID' },
phoneNumber: { type: 'string', description: 'Phone number' },
participant: { type: 'string', description: 'External participant phone number' },
lastMessageAt: { type: 'string', description: 'ISO 8601 timestamp' },
messageCount: { type: 'number', description: 'Number of messages in the conversation' },
metadata: {
type: 'json',
description: 'Custom metadata stored on the conversation',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
messages: {
type: 'array',
description: 'Recent messages in the conversation',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Message ID' },
body: { type: 'string', description: 'Message text' },
fromNumber: { type: 'string', description: 'Sender phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
direction: { type: 'string', description: 'inbound or outbound' },
channel: { type: 'string', description: 'sms, mms, or imessage', optional: true },
mediaUrl: { type: 'string', description: 'Attached media URL', optional: true },
receivedAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
},
}

View File

@@ -0,0 +1,116 @@
import type {
AgentPhoneConversationMessage,
AgentPhoneGetConversationMessagesParams,
AgentPhoneGetConversationMessagesResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetConversationMessagesTool: ToolConfig<
AgentPhoneGetConversationMessagesParams,
AgentPhoneGetConversationMessagesResult
> = {
id: 'agentphone_get_conversation_messages',
name: 'Get Conversation Messages',
description: 'Get paginated messages for a conversation',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
conversationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Conversation ID',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of messages to return (default 50, max 200)',
},
before: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Return messages received before this ISO 8601 timestamp',
},
after: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Return messages received after this ISO 8601 timestamp',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (params.before) query.set('before', params.before)
if (params.after) query.set('after', params.after)
const qs = query.toString()
return `https://api.agentphone.to/v1/conversations/${params.conversationId.trim()}/messages${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetConversationMessagesResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch messages',
output: { data: [], hasMore: false },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(msg: Record<string, unknown>): AgentPhoneConversationMessage => ({
id: (msg.id as string) ?? '',
body: (msg.body as string) ?? '',
fromNumber: (msg.fromNumber as string) ?? '',
toNumber: (msg.toNumber as string) ?? '',
direction: (msg.direction as string) ?? '',
channel: (msg.channel as string | null) ?? null,
mediaUrl: (msg.mediaUrl as string | null) ?? null,
receivedAt: (msg.receivedAt as string) ?? '',
})
),
hasMore: data.hasMore ?? false,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Messages in the conversation',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Message ID' },
body: { type: 'string', description: 'Message text' },
fromNumber: { type: 'string', description: 'Sender phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
direction: { type: 'string', description: 'inbound or outbound' },
channel: { type: 'string', description: 'sms, mms, or imessage', optional: true },
mediaUrl: { type: 'string', description: 'Attached media URL', optional: true },
receivedAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
hasMore: { type: 'boolean', description: 'Whether more messages are available' },
},
}

View File

@@ -0,0 +1,115 @@
import type {
AgentPhoneGetNumberMessagesParams,
AgentPhoneGetNumberMessagesResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetNumberMessagesTool: ToolConfig<
AgentPhoneGetNumberMessagesParams,
AgentPhoneGetNumberMessagesResult
> = {
id: 'agentphone_get_number_messages',
name: 'Get Phone Number Messages',
description: 'Fetch messages received on a specific phone number',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
numberId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the phone number',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of messages to return (default 50, max 200)',
},
before: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Return messages received before this ISO 8601 timestamp',
},
after: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Return messages received after this ISO 8601 timestamp',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (params.before) query.set('before', params.before)
if (params.after) query.set('after', params.after)
const qs = query.toString()
return `https://api.agentphone.to/v1/numbers/${params.numberId.trim()}/messages${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetNumberMessagesResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch messages',
output: { data: [], hasMore: false },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map((msg: Record<string, unknown>) => ({
id: (msg.id as string) ?? '',
from_: (msg.from_ as string) ?? '',
to: (msg.to as string) ?? '',
body: (msg.body as string) ?? '',
direction: (msg.direction as string) ?? '',
channel: (msg.channel as string | null) ?? null,
receivedAt: (msg.receivedAt as string) ?? '',
})),
hasMore: data.hasMore ?? false,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Messages received on the number',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Message ID' },
from_: { type: 'string', description: 'Sender phone number (E.164)' },
to: { type: 'string', description: 'Recipient phone number (E.164)' },
body: { type: 'string', description: 'Message text' },
direction: { type: 'string', description: 'inbound or outbound' },
channel: {
type: 'string',
description: 'Channel (sms, mms, etc.)',
optional: true,
},
receivedAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
hasMore: { type: 'boolean', description: 'Whether more messages are available' },
},
}

View File

@@ -0,0 +1,127 @@
import type { AgentPhoneGetUsageParams, AgentPhoneGetUsageResult } from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetUsageTool: ToolConfig<
AgentPhoneGetUsageParams,
AgentPhoneGetUsageResult
> = {
id: 'agentphone_get_usage',
name: 'Get Usage',
description: 'Retrieve current usage statistics for the AgentPhone account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
},
request: {
url: 'https://api.agentphone.to/v1/usage',
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetUsageResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch usage',
output: {
plan: {
name: '',
limits: {
numbers: null,
messagesPerMonth: null,
voiceMinutesPerMonth: null,
maxCallDurationMinutes: null,
concurrentCalls: null,
},
},
numbers: { used: null, limit: null, remaining: null },
stats: {
totalMessages: null,
messagesLast24h: null,
messagesLast7d: null,
messagesLast30d: null,
totalCalls: null,
callsLast24h: null,
callsLast7d: null,
callsLast30d: null,
totalWebhookDeliveries: null,
successfulWebhookDeliveries: null,
failedWebhookDeliveries: null,
},
periodStart: '',
periodEnd: '',
},
}
}
const planLimits = data?.plan?.limits ?? {}
const numbers = data?.numbers ?? {}
const stats = data?.stats ?? {}
return {
success: true,
output: {
plan: {
name: data?.plan?.name ?? '',
limits: {
numbers: planLimits.numbers ?? null,
messagesPerMonth: planLimits.messagesPerMonth ?? null,
voiceMinutesPerMonth: planLimits.voiceMinutesPerMonth ?? null,
maxCallDurationMinutes: planLimits.maxCallDurationMinutes ?? null,
concurrentCalls: planLimits.concurrentCalls ?? null,
},
},
numbers: {
used: numbers.used ?? null,
limit: numbers.limit ?? null,
remaining: numbers.remaining ?? null,
},
stats: {
totalMessages: stats.totalMessages ?? null,
messagesLast24h: stats.messagesLast24h ?? null,
messagesLast7d: stats.messagesLast7d ?? null,
messagesLast30d: stats.messagesLast30d ?? null,
totalCalls: stats.totalCalls ?? null,
callsLast24h: stats.callsLast24h ?? null,
callsLast7d: stats.callsLast7d ?? null,
callsLast30d: stats.callsLast30d ?? null,
totalWebhookDeliveries: stats.totalWebhookDeliveries ?? null,
successfulWebhookDeliveries: stats.successfulWebhookDeliveries ?? null,
failedWebhookDeliveries: stats.failedWebhookDeliveries ?? null,
},
periodStart: data.periodStart ?? '',
periodEnd: data.periodEnd ?? '',
},
}
},
outputs: {
plan: {
type: 'json',
description:
'Plan name and limits (name, limits: numbers/messagesPerMonth/voiceMinutesPerMonth/maxCallDurationMinutes/concurrentCalls)',
},
numbers: {
type: 'json',
description: 'Phone number usage (used, limit, remaining)',
},
stats: {
type: 'json',
description:
'Usage stats: totalMessages, messagesLast24h/7d/30d, totalCalls, callsLast24h/7d/30d, totalWebhookDeliveries, successfulWebhookDeliveries, failedWebhookDeliveries',
},
periodStart: { type: 'string', description: 'Billing period start' },
periodEnd: { type: 'string', description: 'Billing period end' },
},
}

View File

@@ -0,0 +1,88 @@
import type {
AgentPhoneGetUsageDailyParams,
AgentPhoneGetUsageDailyResult,
AgentPhoneUsageDailyEntry,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetUsageDailyTool: ToolConfig<
AgentPhoneGetUsageDailyParams,
AgentPhoneGetUsageDailyResult
> = {
id: 'agentphone_get_usage_daily',
name: 'Get Daily Usage',
description: 'Get a daily breakdown of usage (messages, calls, webhooks) for the last N days',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
days: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of days to return (1-365, default 30)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.days === 'number') query.set('days', String(params.days))
const qs = query.toString()
return `https://api.agentphone.to/v1/usage/daily${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetUsageDailyResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch daily usage',
output: { data: [], days: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(entry: Record<string, unknown>): AgentPhoneUsageDailyEntry => ({
date: (entry.date as string) ?? '',
messages: (entry.messages as number) ?? 0,
calls: (entry.calls as number) ?? 0,
webhooks: (entry.webhooks as number) ?? 0,
})
),
days: data.days ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Daily usage entries',
items: {
type: 'object',
properties: {
date: { type: 'string', description: 'Day (YYYY-MM-DD)' },
messages: { type: 'number', description: 'Messages that day' },
calls: { type: 'number', description: 'Calls that day' },
webhooks: { type: 'number', description: 'Webhook deliveries that day' },
},
},
},
days: { type: 'number', description: 'Number of days returned' },
},
}

View File

@@ -0,0 +1,88 @@
import type {
AgentPhoneGetUsageMonthlyParams,
AgentPhoneGetUsageMonthlyResult,
AgentPhoneUsageMonthlyEntry,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneGetUsageMonthlyTool: ToolConfig<
AgentPhoneGetUsageMonthlyParams,
AgentPhoneGetUsageMonthlyResult
> = {
id: 'agentphone_get_usage_monthly',
name: 'Get Monthly Usage',
description: 'Get monthly usage aggregation (messages, calls, webhooks) for the last N months',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
months: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of months to return (1-24, default 6)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.months === 'number') query.set('months', String(params.months))
const qs = query.toString()
return `https://api.agentphone.to/v1/usage/monthly${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneGetUsageMonthlyResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to fetch monthly usage',
output: { data: [], months: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(entry: Record<string, unknown>): AgentPhoneUsageMonthlyEntry => ({
month: (entry.month as string) ?? '',
messages: (entry.messages as number) ?? 0,
calls: (entry.calls as number) ?? 0,
webhooks: (entry.webhooks as number) ?? 0,
})
),
months: data.months ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Monthly usage entries',
items: {
type: 'object',
properties: {
month: { type: 'string', description: 'Month (YYYY-MM)' },
messages: { type: 'number', description: 'Messages that month' },
calls: { type: 'number', description: 'Calls that month' },
webhooks: { type: 'number', description: 'Webhook deliveries that month' },
},
},
},
months: { type: 'number', description: 'Number of months returned' },
},
}

View File

@@ -0,0 +1,23 @@
export { agentphoneCreateCallTool } from '@/tools/agentphone/create_call'
export { agentphoneCreateContactTool } from '@/tools/agentphone/create_contact'
export { agentphoneCreateNumberTool } from '@/tools/agentphone/create_number'
export { agentphoneDeleteContactTool } from '@/tools/agentphone/delete_contact'
export { agentphoneGetCallTool } from '@/tools/agentphone/get_call'
export { agentphoneGetCallTranscriptTool } from '@/tools/agentphone/get_call_transcript'
export { agentphoneGetContactTool } from '@/tools/agentphone/get_contact'
export { agentphoneGetConversationTool } from '@/tools/agentphone/get_conversation'
export { agentphoneGetConversationMessagesTool } from '@/tools/agentphone/get_conversation_messages'
export { agentphoneGetNumberMessagesTool } from '@/tools/agentphone/get_number_messages'
export { agentphoneGetUsageTool } from '@/tools/agentphone/get_usage'
export { agentphoneGetUsageDailyTool } from '@/tools/agentphone/get_usage_daily'
export { agentphoneGetUsageMonthlyTool } from '@/tools/agentphone/get_usage_monthly'
export { agentphoneListCallsTool } from '@/tools/agentphone/list_calls'
export { agentphoneListContactsTool } from '@/tools/agentphone/list_contacts'
export { agentphoneListConversationsTool } from '@/tools/agentphone/list_conversations'
export { agentphoneListNumbersTool } from '@/tools/agentphone/list_numbers'
export { agentphoneReactToMessageTool } from '@/tools/agentphone/react_to_message'
export { agentphoneReleaseNumberTool } from '@/tools/agentphone/release_number'
export { agentphoneSendMessageTool } from '@/tools/agentphone/send_message'
export * from '@/tools/agentphone/types'
export { agentphoneUpdateContactTool } from '@/tools/agentphone/update_contact'
export { agentphoneUpdateConversationTool } from '@/tools/agentphone/update_conversation'

View File

@@ -0,0 +1,169 @@
import type {
AgentPhoneCallSummary,
AgentPhoneListCallsParams,
AgentPhoneListCallsResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneListCallsTool: ToolConfig<
AgentPhoneListCallsParams,
AgentPhoneListCallsResult
> = {
id: 'agentphone_list_calls',
name: 'List Calls',
description: 'List voice calls for this AgentPhone account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to return (default 20, max 100)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to skip (min 0)',
},
status: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by status (completed, in-progress, failed)',
},
direction: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by direction (inbound, outbound)',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by call type (pstn, web)',
},
search: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Search by phone number (matches fromNumber or toNumber)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (typeof params.offset === 'number') query.set('offset', String(params.offset))
if (params.status) query.set('status', params.status)
if (params.direction) query.set('direction', params.direction)
if (params.type) query.set('type', params.type)
if (params.search) query.set('search', params.search)
const qs = query.toString()
return `https://api.agentphone.to/v1/calls${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneListCallsResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to list calls',
output: { data: [], hasMore: false, total: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(call: Record<string, unknown>): AgentPhoneCallSummary => ({
id: (call.id as string) ?? '',
agentId: (call.agentId as string | null) ?? null,
phoneNumberId: (call.phoneNumberId as string | null) ?? null,
phoneNumber: (call.phoneNumber as string | null) ?? null,
fromNumber: (call.fromNumber as string) ?? '',
toNumber: (call.toNumber as string) ?? '',
direction: (call.direction as string) ?? '',
status: (call.status as string) ?? '',
startedAt: (call.startedAt as string | null) ?? null,
endedAt: (call.endedAt as string | null) ?? null,
durationSeconds: (call.durationSeconds as number | null) ?? null,
lastTranscriptSnippet: (call.lastTranscriptSnippet as string | null) ?? null,
recordingUrl: (call.recordingUrl as string | null) ?? null,
recordingAvailable: (call.recordingAvailable as boolean | null) ?? null,
})
),
hasMore: data.hasMore ?? false,
total: data.total ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Calls',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Call ID' },
agentId: {
type: 'string',
description: 'Agent that handled the call',
optional: true,
},
phoneNumberId: {
type: 'string',
description: 'Phone number ID used for the call',
optional: true,
},
phoneNumber: {
type: 'string',
description: 'Phone number used for the call',
optional: true,
},
fromNumber: { type: 'string', description: 'Caller phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
direction: { type: 'string', description: 'inbound or outbound', optional: true },
status: { type: 'string', description: 'Call status' },
startedAt: { type: 'string', description: 'ISO 8601 timestamp', optional: true },
endedAt: { type: 'string', description: 'ISO 8601 timestamp', optional: true },
durationSeconds: {
type: 'number',
description: 'Call duration in seconds',
optional: true,
},
lastTranscriptSnippet: {
type: 'string',
description: 'Last transcript snippet',
optional: true,
},
recordingUrl: { type: 'string', description: 'Recording audio URL', optional: true },
recordingAvailable: {
type: 'boolean',
description: 'Whether a recording is available',
optional: true,
},
},
},
},
hasMore: { type: 'boolean', description: 'Whether more results are available' },
total: { type: 'number', description: 'Total number of matching calls' },
},
}

View File

@@ -0,0 +1,110 @@
import type {
AgentPhoneContact,
AgentPhoneListContactsParams,
AgentPhoneListContactsResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneListContactsTool: ToolConfig<
AgentPhoneListContactsParams,
AgentPhoneListContactsResult
> = {
id: 'agentphone_list_contacts',
name: 'List Contacts',
description: 'List contacts for this AgentPhone account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
search: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by name or phone number (case-insensitive contains)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to return (default 50)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to skip (min 0)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (params.search) query.set('search', params.search)
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (typeof params.offset === 'number') query.set('offset', String(params.offset))
const qs = query.toString()
return `https://api.agentphone.to/v1/contacts${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneListContactsResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to list contacts',
output: { data: [], hasMore: false, total: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(c: Record<string, unknown>): AgentPhoneContact => ({
id: (c.id as string) ?? '',
phoneNumber: (c.phoneNumber as string) ?? '',
name: (c.name as string) ?? '',
email: (c.email as string | null) ?? null,
notes: (c.notes as string | null) ?? null,
createdAt: (c.createdAt as string) ?? '',
updatedAt: (c.updatedAt as string) ?? '',
})
),
hasMore: data.hasMore ?? false,
total: data.total ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Contacts',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Contact ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
name: { type: 'string', description: 'Contact name' },
email: { type: 'string', description: 'Contact email address', optional: true },
notes: { type: 'string', description: 'Freeform notes', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
},
},
},
hasMore: { type: 'boolean', description: 'Whether more results are available' },
total: { type: 'number', description: 'Total number of contacts' },
},
}

View File

@@ -0,0 +1,113 @@
import type {
AgentPhoneConversationSummary,
AgentPhoneListConversationsParams,
AgentPhoneListConversationsResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneListConversationsTool: ToolConfig<
AgentPhoneListConversationsParams,
AgentPhoneListConversationsResult
> = {
id: 'agentphone_list_conversations',
name: 'List Conversations',
description: 'List conversations (message threads) for this AgentPhone account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to return (default 20, max 100)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to skip (min 0)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (typeof params.offset === 'number') query.set('offset', String(params.offset))
const qs = query.toString()
return `https://api.agentphone.to/v1/conversations${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneListConversationsResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to list conversations',
output: { data: [], hasMore: false, total: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map(
(conv: Record<string, unknown>): AgentPhoneConversationSummary => ({
id: (conv.id as string) ?? '',
agentId: (conv.agentId as string | null) ?? null,
phoneNumberId: (conv.phoneNumberId as string) ?? '',
phoneNumber: (conv.phoneNumber as string) ?? '',
participant: (conv.participant as string) ?? '',
lastMessageAt: (conv.lastMessageAt as string) ?? '',
lastMessagePreview: (conv.lastMessagePreview as string) ?? '',
messageCount: (conv.messageCount as number) ?? 0,
metadata: (conv.metadata as Record<string, unknown> | null) ?? null,
createdAt: (conv.createdAt as string) ?? '',
})
),
hasMore: data.hasMore ?? false,
total: data.total ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Conversations',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Conversation ID' },
agentId: { type: 'string', description: 'Agent ID', optional: true },
phoneNumberId: { type: 'string', description: 'Phone number ID' },
phoneNumber: { type: 'string', description: 'Phone number' },
participant: { type: 'string', description: 'External participant phone number' },
lastMessageAt: { type: 'string', description: 'ISO 8601 timestamp' },
lastMessagePreview: { type: 'string', description: 'Last message preview' },
messageCount: { type: 'number', description: 'Number of messages in the conversation' },
metadata: {
type: 'json',
description: 'Custom metadata stored on the conversation',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
hasMore: { type: 'boolean', description: 'Whether more results are available' },
total: { type: 'number', description: 'Total number of conversations' },
},
}

View File

@@ -0,0 +1,100 @@
import type {
AgentPhoneListNumbersParams,
AgentPhoneListNumbersResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneListNumbersTool: ToolConfig<
AgentPhoneListNumbersParams,
AgentPhoneListNumbersResult
> = {
id: 'agentphone_list_numbers',
name: 'List Phone Numbers',
description: 'List all phone numbers provisioned for this AgentPhone account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to return (default 20, max 100)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results to skip (min 0)',
},
},
request: {
url: (params) => {
const query = new URLSearchParams()
if (typeof params.limit === 'number') query.set('limit', String(params.limit))
if (typeof params.offset === 'number') query.set('offset', String(params.offset))
const qs = query.toString()
return `https://api.agentphone.to/v1/numbers${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response): Promise<AgentPhoneListNumbersResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to list phone numbers',
output: { data: [], hasMore: false, total: 0 },
}
}
return {
success: true,
output: {
data: (data.data ?? []).map((num: Record<string, unknown>) => ({
id: (num.id as string) ?? '',
phoneNumber: (num.phoneNumber as string) ?? '',
country: (num.country as string) ?? '',
status: (num.status as string) ?? '',
type: (num.type as string) ?? '',
agentId: (num.agentId as string | null) ?? null,
createdAt: (num.createdAt as string) ?? '',
})),
hasMore: data.hasMore ?? false,
total: data.total ?? 0,
},
}
},
outputs: {
data: {
type: 'array',
description: 'Phone numbers',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Phone number ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
country: { type: 'string', description: 'Two-letter country code' },
status: { type: 'string', description: 'Number status' },
type: { type: 'string', description: 'Number type (e.g. sms)', optional: true },
agentId: { type: 'string', description: 'Attached agent ID', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
},
},
},
hasMore: { type: 'boolean', description: 'Whether more results are available' },
total: { type: 'number', description: 'Total number of phone numbers' },
},
}

View File

@@ -0,0 +1,76 @@
import type {
AgentPhoneReactToMessageParams,
AgentPhoneReactToMessageResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneReactToMessageTool: ToolConfig<
AgentPhoneReactToMessageParams,
AgentPhoneReactToMessageResult
> = {
id: 'agentphone_react_to_message',
name: 'React to Message',
description: 'Send an iMessage tapback reaction to a message (iMessage only)',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
messageId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the message to react to',
},
reaction: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Reaction type: love, like, dislike, laugh, emphasize, or question',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/messages/${params.messageId.trim()}/reactions`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => ({ reaction: params.reaction }),
},
transformResponse: async (response, params): Promise<AgentPhoneReactToMessageResult> => {
const data = await response.json()
const messageId = params?.messageId?.trim() ?? ''
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to send reaction',
output: { id: '', reactionType: '', messageId, channel: '' },
}
}
return {
success: true,
output: {
id: data.id ?? '',
reactionType: data.reaction_type ?? '',
messageId: data.message_id ?? messageId,
channel: data.channel ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Reaction ID' },
reactionType: { type: 'string', description: 'Reaction type applied' },
messageId: { type: 'string', description: 'ID of the message that was reacted to' },
channel: { type: 'string', description: 'Channel (imessage)' },
},
}

View File

@@ -0,0 +1,67 @@
import type {
AgentPhoneReleaseNumberParams,
AgentPhoneReleaseNumberResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneReleaseNumberTool: ToolConfig<
AgentPhoneReleaseNumberParams,
AgentPhoneReleaseNumberResult
> = {
id: 'agentphone_release_number',
name: 'Release Phone Number',
description: 'Release (delete) a phone number. This action is irreversible.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
numberId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ID of the phone number to release',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/numbers/${params.numberId.trim()}`,
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response, params): Promise<AgentPhoneReleaseNumberResult> => {
const numberId = params?.numberId?.trim() ?? ''
if (!response.ok) {
let errorMessage = 'Failed to release phone number'
try {
const data = await response.json()
errorMessage = data?.detail?.[0]?.msg ?? data?.message ?? errorMessage
} catch {
// Response body may be empty on DELETE errors; ignore parse failures.
}
return {
success: false,
error: errorMessage,
output: { id: numberId, released: false },
}
}
return {
success: true,
output: { id: numberId, released: true },
}
},
outputs: {
id: { type: 'string', description: 'ID of the released phone number' },
released: { type: 'boolean', description: 'Whether the number was released successfully' },
},
}

View File

@@ -0,0 +1,105 @@
import type {
AgentPhoneSendMessageParams,
AgentPhoneSendMessageResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneSendMessageTool: ToolConfig<
AgentPhoneSendMessageParams,
AgentPhoneSendMessageResult
> = {
id: 'agentphone_send_message',
name: 'Send Message',
description: 'Send an outbound SMS or iMessage from an AgentPhone agent',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
agentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Agent sending the message',
},
toNumber: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Recipient phone number in E.164 format (e.g. +14155551234)',
},
body: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Message text to send',
},
mediaUrl: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional URL of an image, video, or file to attach',
},
numberId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Phone number ID to send from. If omitted, the agent's first assigned number is used.",
},
},
request: {
url: 'https://api.agentphone.to/v1/messages',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {
agent_id: params.agentId,
to_number: params.toNumber,
body: params.body,
}
if (params.mediaUrl) body.media_url = params.mediaUrl
if (params.numberId) body.number_id = params.numberId
return body
},
},
transformResponse: async (response): Promise<AgentPhoneSendMessageResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to send message',
output: { id: '', status: '', channel: '', fromNumber: '', toNumber: '' },
}
}
return {
success: true,
output: {
id: data.id ?? '',
status: data.status ?? '',
channel: data.channel ?? '',
fromNumber: data.from_number ?? '',
toNumber: data.to_number ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Message ID' },
status: { type: 'string', description: 'Delivery status' },
channel: { type: 'string', description: 'sms, mms, or imessage' },
fromNumber: { type: 'string', description: 'Sender phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
},
}

View File

@@ -0,0 +1,450 @@
import type { ToolResponse } from '@/tools/types'
export interface AgentPhoneNumber {
id: string
phoneNumber: string
country: string
status: string
type: string
agentId: string | null
createdAt: string
}
export interface AgentPhoneNumberMessage {
id: string
from_: string
to: string
body: string
direction: string
channel: string | null
receivedAt: string
}
export interface AgentPhoneConversationSummary {
id: string
agentId: string | null
phoneNumberId: string
phoneNumber: string
participant: string
lastMessageAt: string
lastMessagePreview: string
messageCount: number
metadata: Record<string, unknown> | null
createdAt: string
}
export interface AgentPhoneConversationMessage {
id: string
body: string
fromNumber: string
toNumber: string
direction: string
channel: string | null
mediaUrl: string | null
receivedAt: string
}
export interface AgentPhoneConversationDetail {
id: string
agentId: string | null
phoneNumberId: string
phoneNumber: string
participant: string
lastMessageAt: string
messageCount: number
metadata: Record<string, unknown> | null
createdAt: string
messages: AgentPhoneConversationMessage[]
}
export interface AgentPhoneCallSummary {
id: string
agentId: string | null
phoneNumberId: string | null
phoneNumber: string | null
fromNumber: string
toNumber: string
direction: string
status: string
startedAt: string | null
endedAt: string | null
durationSeconds: number | null
lastTranscriptSnippet: string | null
recordingUrl: string | null
recordingAvailable: boolean | null
}
export interface AgentPhoneTranscriptTurn {
id: string
transcript: string
confidence: number | null
response: string | null
createdAt: string
}
export interface AgentPhoneTranscriptEntry {
role: string
content: string
createdAt: string | null
}
export interface AgentPhoneCallDetail extends AgentPhoneCallSummary {
transcripts: AgentPhoneTranscriptTurn[]
}
export interface AgentPhoneContact {
id: string
phoneNumber: string
name: string
email: string | null
notes: string | null
createdAt: string
updatedAt: string
}
export interface AgentPhoneCreateNumberParams {
apiKey: string
country?: string
areaCode?: string
agentId?: string
}
export interface AgentPhoneCreateNumberResult extends ToolResponse {
output: AgentPhoneNumber
}
export interface AgentPhoneListNumbersParams {
apiKey: string
limit?: number
offset?: number
}
export interface AgentPhoneListNumbersResult extends ToolResponse {
output: {
data: AgentPhoneNumber[]
hasMore: boolean
total: number
}
}
export interface AgentPhoneReleaseNumberParams {
apiKey: string
numberId: string
}
export interface AgentPhoneReleaseNumberResult extends ToolResponse {
output: {
id: string
released: boolean
}
}
export interface AgentPhoneGetNumberMessagesParams {
apiKey: string
numberId: string
limit?: number
before?: string
after?: string
}
export interface AgentPhoneGetNumberMessagesResult extends ToolResponse {
output: {
data: AgentPhoneNumberMessage[]
hasMore: boolean
}
}
export interface AgentPhoneCreateCallParams {
apiKey: string
agentId: string
toNumber: string
fromNumberId?: string
initialGreeting?: string
voice?: string
systemPrompt?: string
}
export interface AgentPhoneCreateCallResult extends ToolResponse {
output: {
id: string
agentId: string | null
status: string | null
toNumber: string | null
fromNumber: string | null
phoneNumberId: string | null
direction: string | null
startedAt: string | null
}
}
export interface AgentPhoneListCallsParams {
apiKey: string
limit?: number
offset?: number
status?: string
direction?: string
type?: string
search?: string
}
export interface AgentPhoneListCallsResult extends ToolResponse {
output: {
data: AgentPhoneCallSummary[]
hasMore: boolean
total: number
}
}
export interface AgentPhoneGetCallParams {
apiKey: string
callId: string
}
export interface AgentPhoneGetCallResult extends ToolResponse {
output: AgentPhoneCallDetail
}
export interface AgentPhoneGetCallTranscriptParams {
apiKey: string
callId: string
}
export interface AgentPhoneGetCallTranscriptResult extends ToolResponse {
output: {
callId: string
transcript: AgentPhoneTranscriptEntry[]
}
}
export interface AgentPhoneListConversationsParams {
apiKey: string
limit?: number
offset?: number
}
export interface AgentPhoneListConversationsResult extends ToolResponse {
output: {
data: AgentPhoneConversationSummary[]
hasMore: boolean
total: number
}
}
export interface AgentPhoneGetConversationParams {
apiKey: string
conversationId: string
messageLimit?: number
}
export interface AgentPhoneGetConversationResult extends ToolResponse {
output: AgentPhoneConversationDetail
}
export interface AgentPhoneUpdateConversationParams {
apiKey: string
conversationId: string
metadata?: Record<string, unknown> | null
}
export interface AgentPhoneUpdateConversationResult extends ToolResponse {
output: AgentPhoneConversationDetail
}
export interface AgentPhoneGetConversationMessagesParams {
apiKey: string
conversationId: string
limit?: number
before?: string
after?: string
}
export interface AgentPhoneGetConversationMessagesResult extends ToolResponse {
output: {
data: AgentPhoneConversationMessage[]
hasMore: boolean
}
}
export interface AgentPhoneSendMessageParams {
apiKey: string
agentId: string
toNumber: string
body: string
mediaUrl?: string
numberId?: string
}
export interface AgentPhoneSendMessageResult extends ToolResponse {
output: {
id: string
status: string
channel: string
fromNumber: string
toNumber: string
}
}
export type AgentPhoneReactionType =
| 'love'
| 'like'
| 'dislike'
| 'laugh'
| 'emphasize'
| 'question'
export interface AgentPhoneReactToMessageParams {
apiKey: string
messageId: string
reaction: AgentPhoneReactionType
}
export interface AgentPhoneReactToMessageResult extends ToolResponse {
output: {
id: string
reactionType: string
messageId: string
channel: string
}
}
export interface AgentPhoneCreateContactParams {
apiKey: string
phoneNumber: string
name: string
email?: string
notes?: string
}
export interface AgentPhoneCreateContactResult extends ToolResponse {
output: AgentPhoneContact
}
export interface AgentPhoneListContactsParams {
apiKey: string
search?: string
limit?: number
offset?: number
}
export interface AgentPhoneListContactsResult extends ToolResponse {
output: {
data: AgentPhoneContact[]
hasMore: boolean
total: number
}
}
export interface AgentPhoneGetContactParams {
apiKey: string
contactId: string
}
export interface AgentPhoneGetContactResult extends ToolResponse {
output: AgentPhoneContact
}
export interface AgentPhoneUpdateContactParams {
apiKey: string
contactId: string
phoneNumber?: string
name?: string
email?: string
notes?: string
}
export interface AgentPhoneUpdateContactResult extends ToolResponse {
output: AgentPhoneContact
}
export interface AgentPhoneDeleteContactParams {
apiKey: string
contactId: string
}
export interface AgentPhoneDeleteContactResult extends ToolResponse {
output: {
id: string
deleted: boolean
}
}
export interface AgentPhoneUsagePlan {
name: string
limits: {
numbers: number | null
messagesPerMonth: number | null
voiceMinutesPerMonth: number | null
maxCallDurationMinutes: number | null
concurrentCalls: number | null
}
}
export interface AgentPhoneUsageStats {
totalMessages: number | null
messagesLast24h: number | null
messagesLast7d: number | null
messagesLast30d: number | null
totalCalls: number | null
callsLast24h: number | null
callsLast7d: number | null
callsLast30d: number | null
totalWebhookDeliveries: number | null
successfulWebhookDeliveries: number | null
failedWebhookDeliveries: number | null
}
export interface AgentPhoneGetUsageParams {
apiKey: string
}
export interface AgentPhoneGetUsageResult extends ToolResponse {
output: {
plan: AgentPhoneUsagePlan
numbers: {
used: number | null
limit: number | null
remaining: number | null
}
stats: AgentPhoneUsageStats
periodStart: string
periodEnd: string
}
}
export interface AgentPhoneUsageDailyEntry {
date: string
messages: number
calls: number
webhooks: number
}
export interface AgentPhoneGetUsageDailyParams {
apiKey: string
days?: number
}
export interface AgentPhoneGetUsageDailyResult extends ToolResponse {
output: {
data: AgentPhoneUsageDailyEntry[]
days: number
}
}
export interface AgentPhoneUsageMonthlyEntry {
month: string
messages: number
calls: number
webhooks: number
}
export interface AgentPhoneGetUsageMonthlyParams {
apiKey: string
months?: number
}
export interface AgentPhoneGetUsageMonthlyResult extends ToolResponse {
output: {
data: AgentPhoneUsageMonthlyEntry[]
months: number
}
}

View File

@@ -0,0 +1,114 @@
import type {
AgentPhoneUpdateContactParams,
AgentPhoneUpdateContactResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneUpdateContactTool: ToolConfig<
AgentPhoneUpdateContactParams,
AgentPhoneUpdateContactResult
> = {
id: 'agentphone_update_contact',
name: 'Update Contact',
description: "Update a contact's fields",
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
contactId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Contact ID',
},
phoneNumber: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New phone number in E.164 format',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New contact name',
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New email address',
},
notes: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New freeform notes',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/contacts/${params.contactId.trim()}`,
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.phoneNumber) body.phoneNumber = params.phoneNumber
if (params.name) body.name = params.name
if (params.email) body.email = params.email
if (params.notes) body.notes = params.notes
return body
},
},
transformResponse: async (response): Promise<AgentPhoneUpdateContactResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to update contact',
output: {
id: '',
phoneNumber: '',
name: '',
email: null,
notes: null,
createdAt: '',
updatedAt: '',
},
}
}
return {
success: true,
output: {
id: data.id ?? '',
phoneNumber: data.phoneNumber ?? '',
name: data.name ?? '',
email: data.email ?? null,
notes: data.notes ?? null,
createdAt: data.createdAt ?? '',
updatedAt: data.updatedAt ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Contact ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
name: { type: 'string', description: 'Contact name' },
email: { type: 'string', description: 'Contact email address', optional: true },
notes: { type: 'string', description: 'Freeform notes', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
},
}

View File

@@ -0,0 +1,146 @@
import type {
AgentPhoneConversationMessage,
AgentPhoneUpdateConversationParams,
AgentPhoneUpdateConversationResult,
} from '@/tools/agentphone/types'
import type { ToolConfig } from '@/tools/types'
export const agentphoneUpdateConversationTool: ToolConfig<
AgentPhoneUpdateConversationParams,
AgentPhoneUpdateConversationResult
> = {
id: 'agentphone_update_conversation',
name: 'Update Conversation',
description: 'Update conversation metadata (stored state). Pass null to clear existing metadata.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AgentPhone API key',
},
conversationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Conversation ID',
},
metadata: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Custom key-value metadata to store on the conversation. Pass null to clear existing metadata.',
},
},
request: {
url: (params) => `https://api.agentphone.to/v1/conversations/${params.conversationId.trim()}`,
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.metadata !== undefined) body.metadata = params.metadata
return body
},
},
transformResponse: async (response): Promise<AgentPhoneUpdateConversationResult> => {
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data?.detail?.[0]?.msg ?? data?.message ?? 'Failed to update conversation',
output: {
id: '',
agentId: null,
phoneNumberId: '',
phoneNumber: '',
participant: '',
lastMessageAt: '',
messageCount: 0,
metadata: null,
createdAt: '',
messages: [],
},
}
}
const rawMessages = Array.isArray(data?.messages) ? data.messages : []
const messages: AgentPhoneConversationMessage[] = rawMessages.map(
(message: Record<string, unknown>) => ({
id: (message.id as string) ?? '',
body: (message.body as string) ?? '',
fromNumber: (message.fromNumber as string) ?? '',
toNumber: (message.toNumber as string) ?? '',
direction: (message.direction as string) ?? '',
channel: (message.channel as string | null) ?? null,
mediaUrl: (message.mediaUrl as string | null) ?? null,
receivedAt: (message.receivedAt as string) ?? '',
})
)
return {
success: true,
output: {
id: data.id ?? '',
agentId: data.agentId ?? null,
phoneNumberId: data.phoneNumberId ?? '',
phoneNumber: data.phoneNumber ?? '',
participant: data.participant ?? '',
lastMessageAt: data.lastMessageAt ?? '',
messageCount: data.messageCount ?? 0,
metadata: data.metadata ?? null,
createdAt: data.createdAt ?? '',
messages,
},
}
},
outputs: {
id: { type: 'string', description: 'Conversation ID' },
agentId: { type: 'string', description: 'Agent ID', optional: true },
phoneNumberId: { type: 'string', description: 'Phone number ID' },
phoneNumber: { type: 'string', description: 'Phone number' },
participant: { type: 'string', description: 'External participant phone number' },
lastMessageAt: { type: 'string', description: 'ISO 8601 timestamp' },
messageCount: { type: 'number', description: 'Number of messages' },
metadata: {
type: 'json',
description: 'Custom metadata stored on the conversation',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
messages: {
type: 'array',
description: 'Messages in the conversation',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Message ID' },
body: { type: 'string', description: 'Message body' },
fromNumber: { type: 'string', description: 'Sender phone number' },
toNumber: { type: 'string', description: 'Recipient phone number' },
direction: { type: 'string', description: 'inbound or outbound' },
channel: {
type: 'string',
description: 'Channel (sms, mms, etc.)',
optional: true,
},
mediaUrl: {
type: 'string',
description: 'Media URL if any',
optional: true,
},
receivedAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
},
},
},
}

View File

@@ -31,6 +31,30 @@ import {
agentmailUpdateMessageTool,
agentmailUpdateThreadTool,
} from '@/tools/agentmail'
import {
agentphoneCreateCallTool,
agentphoneCreateContactTool,
agentphoneCreateNumberTool,
agentphoneDeleteContactTool,
agentphoneGetCallTool,
agentphoneGetCallTranscriptTool,
agentphoneGetContactTool,
agentphoneGetConversationMessagesTool,
agentphoneGetConversationTool,
agentphoneGetNumberMessagesTool,
agentphoneGetUsageDailyTool,
agentphoneGetUsageMonthlyTool,
agentphoneGetUsageTool,
agentphoneListCallsTool,
agentphoneListContactsTool,
agentphoneListConversationsTool,
agentphoneListNumbersTool,
agentphoneReactToMessageTool,
agentphoneReleaseNumberTool,
agentphoneSendMessageTool,
agentphoneUpdateContactTool,
agentphoneUpdateConversationTool,
} from '@/tools/agentphone'
import {
agiloftAttachFileTool,
agiloftAttachmentInfoTool,
@@ -2903,6 +2927,28 @@ export const tools: Record<string, ToolConfig> = {
agentmail_update_inbox: agentmailUpdateInboxTool,
agentmail_update_message: agentmailUpdateMessageTool,
agentmail_update_thread: agentmailUpdateThreadTool,
agentphone_create_call: agentphoneCreateCallTool,
agentphone_create_contact: agentphoneCreateContactTool,
agentphone_create_number: agentphoneCreateNumberTool,
agentphone_delete_contact: agentphoneDeleteContactTool,
agentphone_get_call: agentphoneGetCallTool,
agentphone_get_call_transcript: agentphoneGetCallTranscriptTool,
agentphone_get_contact: agentphoneGetContactTool,
agentphone_get_conversation: agentphoneGetConversationTool,
agentphone_get_conversation_messages: agentphoneGetConversationMessagesTool,
agentphone_get_number_messages: agentphoneGetNumberMessagesTool,
agentphone_get_usage: agentphoneGetUsageTool,
agentphone_get_usage_daily: agentphoneGetUsageDailyTool,
agentphone_get_usage_monthly: agentphoneGetUsageMonthlyTool,
agentphone_list_calls: agentphoneListCallsTool,
agentphone_list_contacts: agentphoneListContactsTool,
agentphone_list_conversations: agentphoneListConversationsTool,
agentphone_list_numbers: agentphoneListNumbersTool,
agentphone_react_to_message: agentphoneReactToMessageTool,
agentphone_release_number: agentphoneReleaseNumberTool,
agentphone_send_message: agentphoneSendMessageTool,
agentphone_update_contact: agentphoneUpdateContactTool,
agentphone_update_conversation: agentphoneUpdateConversationTool,
agiloft_attach_file: agiloftAttachFileTool,
agiloft_attachment_info: agiloftAttachmentInfoTool,
agiloft_create_record: agiloftCreateRecordTool,

View File

@@ -10,12 +10,27 @@ if (!connectionString) {
throw new Error('Missing DATABASE_URL environment variable')
}
/**
* Server-side safety net for runaway queries and abandoned transactions:
* - `statement_timeout=90000` kills any single statement still running
* after 90s. Protects against pathological queries.
* - `idle_in_transaction_session_timeout=90000` kills a session that has
* opened a transaction and gone idle for 90s. Protects against
* transactions that hold row locks while waiting on external I/O.
*
* These are last-resort caps — application code should never approach
* them. Migrations or admin scripts that legitimately need longer limits
* must construct their own client with overrides.
*/
const postgresClient = postgres(connectionString, {
prepare: false,
idle_timeout: 20,
connect_timeout: 30,
max: 30,
onnotice: () => {},
connection: {
options: '-c statement_timeout=90000 -c idle_in_transaction_session_timeout=90000',
},
})
export const db = drizzle(postgresClient, { schema })

View File

@@ -0,0 +1,4 @@
ALTER TABLE "organization" ADD COLUMN "data_retention_settings" json;--> statement-breakpoint
ALTER TABLE "workspace" DROP COLUMN "log_retention_hours";--> statement-breakpoint
ALTER TABLE "workspace" DROP COLUMN "soft_delete_retention_hours";--> statement-breakpoint
ALTER TABLE "workspace" DROP COLUMN "task_cleanup_hours";

View File

@@ -0,0 +1 @@
ALTER TABLE "workspace_files" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1366,6 +1366,20 @@
"when": 1776883116756,
"tag": "0195_normal_white_queen",
"breakpoints": true
},
{
"idx": 196,
"version": "7",
"when": 1776916912442,
"tag": "0196_retention_org_level",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1776980739421,
"tag": "0197_unknown_the_captain",
"breakpoints": true
}
]
}

View File

@@ -980,6 +980,11 @@ export const organization = pgTable('organization', {
privacyUrl?: string
hidePoweredBySim?: boolean
}>(),
dataRetentionSettings: json('data_retention_settings').$type<{
logRetentionHours?: number | null
softDeleteRetentionHours?: number | null
taskCleanupHours?: number | null
}>(),
orgUsageLimit: decimal('org_usage_limit'),
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
@@ -1079,9 +1084,6 @@ export const workspace = pgTable(
inboxEnabled: boolean('inbox_enabled').notNull().default(false),
inboxAddress: text('inbox_address'),
inboxProviderId: text('inbox_provider_id'),
logRetentionHours: integer('log_retention_hours'),
softDeleteRetentionHours: integer('soft_delete_retention_hours'),
taskCleanupHours: integer('task_cleanup_hours'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
@@ -1136,6 +1138,7 @@ export const workspaceFiles = pgTable(
size: integer('size').notNull(),
deletedAt: timestamp('deleted_at'),
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique')