Skip to content
MeridFlow AiFlow v8.x • self-hosted

Calls

The call log covers every phone call, web or widget session, and silent agent_task run an agent has ever recorded, along with its full turn-by-turn transcript. It also includes an endpoint for placing a manual outbound call for testing, without a real event or Trigger behind it.

Endpoint family

GET  /api/v1/agents/{agent_id}/calls
GET  /api/v1/agents/{agent_id}/calls/{call_id}
GET  /api/v1/agents/{agent_id}/calls/{call_id}/turns
POST /api/v1/agents/{agent_id}/calls
POST /api/v1/agents/{agent_id}/calls/{call_id}/monitor
POST /api/v1/agents/{agent_id}/calls/{call_id}/take-over

Authentication and roles

Every endpoint here takes an admin session token (see Authentication), not an API key.

Endpoint Required role
GET (list, detail, turns) Owner, Admin, Editor, or Viewer
POST (place a manual call) Owner or Admin
POST .../monitor (listen in) Owner, Admin, Editor, or Viewer
POST .../take-over Owner or Admin

Reading the call log and transcripts is available to every admin role, including Viewer, since that's the read-only observability this role exists for. Placing a call is a live action with real Twilio cost behind it, so it's held to the same higher bar as managing API keys and other admins. An Editor cannot use this endpoint, even though Editors can create the Triggers that place calls indirectly.

List calls

GET /api/v1/agents/{agent_id}/calls

Returns every call on the agent, newest first (ordered by id descending), with no pagination and no filters.

curl https://api.your-domain.com/api/v1/agents/1/calls \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/calls",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
calls = response.json()
const response = await fetch("https://api.your-domain.com/api/v1/agents/1/calls", {
  headers: { Authorization: `Bearer ${adminToken}` },
});
const calls = await response.json();
[
  {
    "id": 456,
    "agent_id": 1,
    "session_id": "a1b2c3d4e5f6...",
    "direction": "outbound",
    "channel": "phone",
    "status": "completed",
    "phone_number": "+15557654321",
    "from_number": "+15551112222",
    "visitor_name": null,
    "visitor_email": null,
    "twilio_call_sid": "CAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "triggered_by_event_id": 123,
    "triggered_by_trigger_id": 4,
    "summary": "Caller confirmed they still want the items in cart 89 and asked for a discount code.",
    "error_message": null,
    "sentiment": "positive",
    "outcome_tag": "resolved",
    "started_at": "2026-01-15T10:30:05Z",
    "ended_at": "2026-01-15T10:32:47Z",
    "duration_seconds": 162,
    "created_at": "2026-01-15T10:30:00Z"
  }
]

Get a single call

GET /api/v1/agents/{agent_id}/calls/{call_id}

Returns 404 if call_id doesn't exist, or belongs to a different agent than the one in the URL.

curl https://api.your-domain.com/api/v1/agents/1/calls/456 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/calls/456",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
call = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/calls/456",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const call = await response.json();

Fields

Field Type Description
id integer The call's id.
agent_id integer The agent this call belongs to.
session_id string An opaque session identifier generated when the call was created.
direction string inbound or outbound.
channel string web (widget), phone, or agent_task (a headless, silent run started by an agent_task Trigger, see Triggers, no audio, no human on the line).
status string pending, ringing, in_progress, completed, failed, no_answer, busy, or transferred (handed off to a human via the agent's transfer_to_human tool).
phone_number string or null The recipient's number for a phone-channel call; null for web and agent_task.
from_number string or null The Twilio number the call placed from.
visitor_name string or null Set for a web-channel call if the widget visitor supplied a name.
visitor_email string or null Same, for email.
twilio_call_sid string or null Twilio's own call identifier, useful for cross-referencing the Twilio console or API directly.
caller_line_type string or null The number's line type (mobile, landline, nonFixedVoip, ...) from Twilio Lookup, when Lookup screening is enabled; null otherwise.
answered_by string or null Twilio's Answering Machine Detection verdict for an outbound call (human, machine_end_beep, fax, ...), when the agent has detection enabled; null otherwise. A machine_* or fax value pairs with status no_answer and error_message "reached voicemail".
quality object or null Twilio Voice Insights audio-quality summary (quality_score, mos_avg, jitter_avg, packet_loss_pct), present a couple of minutes after a phone call ends when Voice Insights fetching is enabled.
triggered_by_event_id integer or null The event that queued this call, if any (null for a manual call placed via POST below, or an inbound call).
triggered_by_trigger_id integer or null The Trigger that queued this call, if any.
summary string or null An AI-generated summary of the conversation, written after the call ends.
error_message string or null Set when status is failed.
sentiment string or null positive, neutral, or negative, classified after the call ends; null until classified (or forever, for a call too short to judge). See Analytics.
outcome_tag string or null A short free-text outcome (e.g. "resolved", "booked appointment"), classified alongside sentiment.
started_at datetime or null When the call actually connected; null if it never got past pending/ringing.
ended_at datetime or null When the call ended.
duration_seconds integer or null Connected duration.
created_at datetime When the Call record was created (before dialing, for an outbound call).

Get a call's transcript

GET /api/v1/agents/{agent_id}/calls/{call_id}/turns

Returns every Turn in the conversation, ordered by sequence.

The transcript is written live, not reconstructed afterward

Each Turn is saved as the conversation happens, one row per utterance (buffered by speaker and flushed on a role change or a turn-complete signal from the model), not assembled from an end-of-call summary. That means you can poll this endpoint against an in_progress call and see turns accumulate in near real time, without waiting for status to reach completed. The call's summary field is a separate, AI-generated artifact written only once the call ends; the turn list is the actual record.

curl https://api.your-domain.com/api/v1/agents/1/calls/456/turns \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/calls/456/turns",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
turns = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/calls/456/turns",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const turns = await response.json();
[
  {
    "id": 9001,
    "role": "agent",
    "text": "Hi Prince, I'm calling about your cart worth $89. Is now a good time?",
    "tool_name": null,
    "tool_args": null,
    "tool_result": null,
    "sequence": 0,
    "created_at": "2026-01-15T10:30:06Z"
  },
  {
    "id": 9002,
    "role": "user",
    "text": "Sure, go ahead.",
    "tool_name": null,
    "tool_args": null,
    "tool_result": null,
    "sequence": 1,
    "created_at": "2026-01-15T10:30:11Z"
  },
  {
    "id": 9003,
    "role": "tool",
    "text": null,
    "tool_name": "lookup_discount_code",
    "tool_args": { "customer_id": "cust_881" },
    "tool_result": "SAVE10",
    "sequence": 2,
    "created_at": "2026-01-15T10:30:22Z"
  }
]

Fields

Field Type Description
id integer The turn's id.
role string user, agent, system, or tool.
text string or null The spoken/written content, for user/agent/system turns; typically null for a tool turn.
tool_name string or null The tool called, for a tool turn.
tool_args object or null The arguments the model passed to the tool.
tool_result string or null The tool's return value, as text.
sequence integer The turn's position in the conversation; this is the sort key, not id or created_at.
created_at datetime When this turn was persisted.

Place a manual outbound call

POST /api/v1/agents/{agent_id}/calls

Queues an outbound call immediately, bypassing event ingestion and Trigger matching entirely, but going through the exact same durable call queue an event-triggered call uses. Use this to test an agent's phone setup (voice, greeting, tools) end to end without wiring up a real event source or Trigger first.

Request

Field Type Required Description
phone_number string Yes The number to call, 1-20 characters.
context string or null No Static dynamic-context text for the agent's opening, the manual-call equivalent of a Trigger's rendered context_template. No Jinja2 rendering happens here, it's used verbatim.
curl -X POST https://api.your-domain.com/api/v1/agents/1/calls \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "phone_number": "+15557654321",
        "context": "This is a manual test call."
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/agents/1/calls",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "phone_number": "+15557654321",
        "context": "This is a manual test call.",
    },
)
response.raise_for_status()
call = response.json()
const response = await fetch("https://api.your-domain.com/api/v1/agents/1/calls", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${adminToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    phone_number: "+15557654321",
    context: "This is a manual test call.",
  }),
});
const call = await response.json();

Returns 201 Created with the new Call record (direction: "outbound", channel: "phone", status: "pending", triggered_by_event_id and triggered_by_trigger_id both null). Poll GET /api/v1/agents/1/calls/{id} or its /turns to watch it progress.

Returns 422 Unprocessable Entity if the agent has no twilio_phone_number configured. Without one, there's no number for AiFlow to place the call from, so it can't be queued at all.

Listen in on a live call

POST /api/v1/agents/{agent_id}/calls/{call_id}/monitor

Starts a listen-in session on an in_progress, phone-channel call: a second, independent audio stream is forked from the live call (without ending its Gemini Live session) and relayed to the admin dashboard over its own WebSocket. It's available to any admin role, the same read-only bar as viewing a transcript.

Live monitoring is licensed. On a plan without it this route returns 404, like every other unlicensed surface. Taking over a call is gated separately, on warm transfer, since that is what it actually does and it does not require having listened in first.

curl -X POST https://api.your-domain.com/api/v1/agents/1/calls/456/monitor \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{ "token": "eyJhbGciOiJIUzI1NiIs..." }

The returned token is a short-lived (5-minute), single-purpose credential for wss://api.your-domain.com/api/v1/twilio/monitor-listen ?call_id={call_id}&token={token}. Connect there and the socket delivers raw 16-bit PCM audio frames (8kHz, mono) as binary WebSocket messages until you disconnect or the call ends. There's no REST call to stop listening: closing the WebSocket is enough.

Returns 422 Unprocessable Entity if the call isn't a phone-channel call currently in_progress, or 502 Bad Gateway if Twilio rejects the request to fork the stream.

Take over a call

POST /api/v1/agents/{agent_id}/calls/{call_id}/take-over

Redirects a live, in_progress phone call to a human, ending the AI's turn immediately. It's the same conference-based handoff the agent's own transfer_to_human tool uses, just triggered by an admin (for example, one who was listening in) instead of the AI deciding to transfer. Requires Owner or Admin, the same bar as placing a manual call.

Request

Field Type Required Description
phone_number string Yes The number to redirect the call to, 1-20 characters.
reason string or null No A short note; defaults to a generic "admin took over" message.
curl -X POST https://api.your-domain.com/api/v1/agents/1/calls/456/take-over \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"phone_number": "+15557654321"}'

Returns 204 No Content on success, and the call's status becomes transferred. Returns 422 Unprocessable Entity if the call isn't a phone-channel call currently in_progress, or 502 Bad Gateway if Twilio rejects the transfer.

Next

  • Triggers: the rules that queue calls automatically from real traffic, instead of the manual path above.
  • Event log: find the event behind a triggered_by_event_id.
  • Webhook subscriptions: get notified asynchronously (call.finished or call.failed) instead of polling this page.