Quickstart
From nothing to a published assistant with a number pointed at it, using the REST API.
Every command on this page was run against a local control plane while this page was written. Where a step needs an account we do not have — a carrier, a model vendor — the page says so instead of pretending.
You need the stack running. Install first, then check it:
php apps/control-plane/artisan callagent:checkThat prints one row per dependency. Do not start until every row says OK.
export CALLAGENT_API_URL="http://localhost:8000/api/v1"
export CALLAGENT_API_KEY="ca_sk_…" # created in step 11. Get an API key
Two kinds exist and the difference matters.
| Kind | Prefix | Where it is safe | What it can do |
|---|---|---|---|
| Private | ca_sk_ | Your server, your CI, your terminal | Everything its scopes allow |
| Public | ca_pk_ | A browser bundle, a public web page | Exchange itself for a short-lived client token, nothing else |
Keys are created in the dashboard. There is no artisan command and no CLI command that mints one — the REST endpoint POST /api-keys needs a key to call it, so the first key has to come from a signed-in session. Open the control plane, pick your organization, and go to API keys:
http://localhost:8000/{organization-slug}/api-keysA private key's plaintext is shown once. After that the database holds only sha256(key), so "show it to me again" is answered with "rotate it".
A public key is refused unless you give it at least one allowed origin. That refusal is deliberate: a public key with no origin restriction is just a leaked key.
Check that the key works:
curl "$CALLAGENT_API_URL/api-keys/scopes" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"That returns the whole scope vocabulary, grouped. A key created with no scopes has all of them; scopes narrow a key, they do not grant it anything.
Either header works
Authorization: Bearer <key> and X-CallAgent-Key: <key> are both accepted. Use whichever your HTTP client makes easy.
2. Store provider credentials
An assistant in the default pipeline mode needs three vendors: a transcriber, a model and a voice. Each needs a credential stored for the organization.
secrets is required. A create call without it is a 422, not a draft.
curl -X POST "$CALLAGENT_API_URL/credentials" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"class": "model",
"provider": "openai",
"name": "OpenAI",
"secrets": { "api_key": "sk-…" }
}'class is one of telephony, model, transcriber, voice, storage, observability, integration, mcp, oauth, sip.
The secret field names differ by vendor, because they are the names the vendor uses:
| Credential | secrets keys |
|---|---|
| OpenAI, Deepgram, ElevenLabs, AssemblyAI, Cartesia, Telnyx, DIDWW, Sonix | api_key |
| Twilio | account_sid, auth_token |
| PlayHT | api_key, user_id |
| Langfuse | public_key, secret_key, plus a host in config |
Now prove the credential actually works. This endpoint makes a real HTTP request to the vendor with the key you stored:
curl -X POST "$CALLAGENT_API_URL/credentials/$CREDENTIAL_ID/test" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"It answers 200 whichever way the verdict goes, because the request succeeded — the credential's verdict is the payload:
{
"data": {
"ok": false,
"verified": true,
"provider": "OpenAI",
"message": "OpenAI rejected the credential as unauthenticated. …",
"http_status": 401,
"endpoint": "https://api.openai.com/v1/models"
}
}Read ok and verified as a pair:
verified | ok | Meaning |
|---|---|---|
true | true | The vendor accepted the key. This is the only green. |
true | false | The vendor rejected the key. message says why. |
false | false | Nobody asked the vendor. Either CallAgent has no verification call for that provider, or a required field is missing. Not a failure — an absence of evidence. |
Repeat for "class": "transcriber" and "class": "voice". Which providers exist is in the registry: GET /registry/models, GET /registry/transcribers, GET /registry/voices.
3. Create an assistant
The assistants API is snake_case. (The telephony API is camelCase. That is an inconsistency in the product, not a typo here — see API conventions.)
curl -X POST "$CALLAGENT_API_URL/assistants" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Support Line",
"language": "en",
"first_message_mode": "assistant_speaks_first",
"first_message": "Hi, you have reached support. How can I help?",
"system_prompt": "You are a support agent for Example Ltd. Be brief. Ask one question at a time. Never invent an order number.",
"transcriber": { "provider": "deepgram" },
"model": { "provider": "openai", "model": "gpt-4o-mini" },
"voice": { "provider": "elevenlabs", "voice_id": "rachel" },
"silence_timeout_seconds": 30,
"max_duration_seconds": 600
}'const auth = { Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}` };
const base = process.env.CALLAGENT_API_URL;
const created = await fetch(`${base}/assistants`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Support Line',
language: 'en',
first_message_mode: 'assistant_speaks_first',
first_message: 'Hi, you have reached support. How can I help?',
system_prompt:
'You are a support agent for Example Ltd. Be brief. Ask one question at a time.',
transcriber: { provider: 'deepgram' },
model: { provider: 'openai', model: 'gpt-4o-mini' },
voice: { provider: 'elevenlabs', voice_id: 'rachel' },
silence_timeout_seconds: 30,
max_duration_seconds: 600,
}),
});
if (!created.ok) throw new Error(await created.text());
const assistantId = (await created.json()).data.id;import os, requests
base = os.environ["CALLAGENT_API_URL"]
auth = {"Authorization": f"Bearer {os.environ['CALLAGENT_API_KEY']}"}
response = requests.post(f"{base}/assistants", headers=auth, timeout=30, json={
"name": "Support Line",
"language": "en",
"first_message_mode": "assistant_speaks_first",
"first_message": "Hi, you have reached support. How can I help?",
"system_prompt": "You are a support agent for Example Ltd. Be brief.",
"transcriber": {"provider": "deepgram"},
"model": {"provider": "openai", "model": "gpt-4o-mini"},
"voice": {"provider": "elevenlabs", "voice_id": "rachel"},
})
response.raise_for_status()
assistant_id = response.json()["data"]["id"]use Illuminate\Support\Facades\Http;
$assistant = Http::withToken(env('CALLAGENT_API_KEY'))
->acceptJson()
->post(env('CALLAGENT_API_URL').'/assistants', [
'name' => 'Support Line',
'language' => 'en',
'first_message_mode' => 'assistant_speaks_first',
'first_message' => 'Hi, you have reached support. How can I help?',
'system_prompt' => 'You are a support agent for Example Ltd. Be brief.',
'transcriber' => ['provider' => 'deepgram'],
'model' => ['provider' => 'openai', 'model' => 'gpt-4o-mini'],
'voice' => ['provider' => 'elevenlabs', 'voice_id' => 'rachel'],
])
->throw()
->json('data');first_message_mode uses underscores over the API
The three accepted values are assistant_speaks_first, assistant_speaks_first_with_model_generated_message and assistant_waits_for_user.
The gateway's own contract spells the same three values with hyphens (assistant-speaks-first), and the control plane converts on the way out. If you are reading @callagent/contracts and writing REST, convert. A hyphenated value over the API is a 422 with "The selected who speaks first is invalid."
4. Validate, then publish
Editing an assistant changes its draft. A call runs a published version, and it pins the version it started with, so republishing mid-call cannot reach a live session.
curl "$CALLAGENT_API_URL/assistants/$ASSISTANT_ID/validate" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"{ "data": { "valid": true, "errors": [] } }Know what validate does and does not check. It checks:
- a name and a system prompt are present;
model.providerandmodel.modelare both set;- a pipeline assistant has a
voice.providerand atranscriber.provider; - the transcriber is one the gateway has an adapter for, and is not a batch-only vendor. This check exists because a published assistant once ran with a batch transcriber, the gateway rejected the whole call config, and the caller heard a dead line;
max_duration_secondsis within the platform ceiling;- knowledge-only mode has at least one knowledge base;
- the prompt and first-message templates parse.
It does not check that a credential exists for the providers you named, that it works, or that attached tools resolve. valid: true with no credentials at all is the normal answer. Prove credentials in step 2, not here.
The REST API cannot set voice.voice_id, and a call needs it
This is the most important thing on the page, so read it twice.
The converter that builds the gateway's call config requires five fields: model.provider, model.model, transcriber.provider, voice.provider and voice.voice_id. If any one is absent it returns nothing at all — not a partial config — and the call has no configuration to run. The caller hears a dead line.
validate checks four of the five. It does not check voice_id. But the real problem is worse than a missing check:
POST and PATCH /assistants silently drop voice.voice_id. The controller uses Laravel's $request->validated(), which returns only the keys that had a validation rule. The request class declares rules for transcriber.provider, model.provider, model.model, voice.provider and server.url — and for nothing else inside those four blocks. Every other sub-key is discarded before it reaches the database.
Reproduced on a live control plane:
# sent
"voice": { "provider": "elevenlabs", "voice_id": "rachel", "speed": 1.1 }
# stored, and returned by the next GET
"voice": { "provider": "elevenlabs" }The same happens to model.temperature, model.max_tokens, transcriber.model, transcriber.language and every other tuning field on those three blocks. 201, no warning, and the pruned value is right there in the response if you look.
Consequence: an assistant whose voice block was last written through the REST API cannot start a call. The CLI posts to the same endpoint, so callagent apply has the same problem.
What does work: the plan blocks (start_speaking_plan, artifact_plan and the rest) have no sub-rules, so they pass through whole — as do metadata, variable_values and background_sound. And the dashboard writes the model directly rather than through this request class, so an assistant configured there gets a complete voice block.
Until this is fixed, set the voice in the dashboard. Use the API for everything else.
preview returns the resolved snapshot — every plan and default filled in:
curl "$CALLAGENT_API_URL/assistants/$ASSISTANT_ID/preview" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"preview is not the gateway's wire format
preview returns the resolved snapshot in the API's snake_case shape. The gateway receives that snapshot run through a second conversion into the camelCase contract shape (AssistantSnapshotWire), which also drops tools it cannot map. Use preview to see what was resolved, not to predict the bytes on the wire.
Publish:
curl -X POST "$CALLAGENT_API_URL/assistants/$ASSISTANT_ID/publish" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"201 with an assistant_version object: version, config_hash, tool_refs, published_at. That row is immutable.
5. Give it a number
If you already own the number (BYO / SIP)
POST /telephony/phone-numbers registers a number you control. This endpoint is camelCase, and it is also where you point the number at an assistant:
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"number": "+40312345901",
"friendlyName": "Support",
"assistantId": "'"$ASSISTANT_ID"'"
}'The row comes back with provider: "byo_sip", destination_type: "assistant" and destination_id set to your assistant.
To repoint an existing number, PATCH it:
curl -X PATCH "$CALLAGENT_API_URL/telephony/phone-numbers/$NUMBER_ID" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "destinationType": "assistant", "destinationId": "'"$ASSISTANT_ID"'" }'destinationType is one of assistant, squad, queue, ivr, ring_group, extension, voicemail, external, sip_uri, hook.
If you want to buy one
Search needs a provider — there is no default carrier:
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers/search" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "twilio", "country": "RO", "type": "local" }'then
curl -X POST "$CALLAGENT_API_URL/telephony/phone-numbers/purchase" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "twilio", "number": "+40312345678" }'Searching and purchasing both call the carrier, so they need a working telephony credential from step 2. Of the nine provider ids the enum declares, six have an adapter and only two can search and buy: see Telephony.
configure does not mean "point it at an assistant"
POST /telephony/phone-numbers/{id}/configure takes no request body. It pushes CallAgent's webhook URLs to the carrier for that number and reports { "configured": …, "webhooks_in_sync": … }. Run it after a purchase, or after your public URL changes. Routing is destinationType / destinationId, above.
6. Place an outbound call
curl -X POST "$CALLAGENT_API_URL/calls" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistant_id": "'"$ASSISTANT_ID"'",
"phone_number_id": "'"$NUMBER_ID"'",
"to": "+40712345678",
"idempotency_key": "quickstart-001"
}'idempotency_key can also travel as an Idempotency-Key header. It is what makes retrying a timed-out request safe rather than dialling someone twice: a repeat of the same key in the same organization returns the original call row untouched.
A 201 does not mean the call connected
The response is 201 as soon as the call row exists. Read status and ended_reason. Without a carrier configured you get exactly this, which is what happened when this page was written:
{
"data": {
"id": "01M1ZKWXMFRDX6TK3QYR2EV9VY",
"status": "failed",
"ended_reason": "provider_error",
"duration_seconds": 0
}
}Every dial is admitted before a leg is created: organization status, dunning freeze, daily spend cap, plan volume ceilings, credit balance, concurrency headroom, destination policy (allowlist, blocklist, country, premium and satellite prefixes, hourly and daily caps) and caller-ID ownership. The concurrency slot is released on finalisation even if the call crashes. A refusal comes back as an ended_reason of concurrency_limit, destination_blocked or telephony_error rather than as a vague 500.
7. Read what happened
curl "$CALLAGENT_API_URL/calls/$CALL_ID" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/events" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/transcript" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/messages" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/metrics" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/costs" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/artifacts" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/recording" -H "Authorization: Bearer $CALLAGENT_API_KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/debug" -H "Authorization: Bearer $CALLAGENT_API_KEY"events is populated even for a call that never connected — call.created, call.state.changed for every transition, with an offset_ms from call start. That is the first place to look when a call failed and you do not know where.
metrics breaks a turn down by pipeline stage. costs breaks the money down per component and tells you which components were priced at all. recording is a 404 with {"error":{"code":"not_found"}} when there is no recording, not an empty 200.
Rather than polling, subscribe — see Webhooks — or forward events to your laptop:
callagent listen --forward-to http://localhost:3000/hooksWhat you cannot do yet on a laptop
Being honest about where this quickstart stops:
| State | |
|---|---|
Everything above, through POST /calls | Works locally, no external account. |
| A real PSTN call | Needs a Twilio account, a voice-capable number and a publicly reachable URL. Twilio is the only carrier proven to reach the AI pipeline. |
| A real SIP trunk call | Trunk registration and IP authentication are proven against a lab peer. No SIP trunk call has ever reached an assistant — the media handoff between FreeSWITCH and the gateway is not built. See SIP. |
| Speech-to-speech | Configurable, stored, and ignored at run time. See What is not built. |
| Anything the model or voice actually says | Needs vendor keys with credit. Nothing on this page synthesises audio. |
Or do all of that in git
# assistants/support-line.yaml
name: Support Line
firstMessageMode: assistant-speaks-first
firstMessage: Hi, you have reached support. How can I help?
systemPrompt: |
You are a support agent for Example Ltd.
Be brief. Ask one question at a time. Never invent an order number.
transcriber: { provider: deepgram, model: nova-3 }
model: { provider: openai, model: gpt-4o-mini, temperature: 0.4 }
voice: { provider: elevenlabs, voiceId: rachel }callagent validate
callagent plan # a field-level diff, with the actual before and after
callagent applyThe CLI speaks the @callagent/contracts vocabulary — camelCase, and assistant-speaks-first with hyphens — and converts to the API's spelling itself. See Config as code.
Next
- How a call works — what a turn is made of, stage by stage.
- Turn taking & barge-in — the first thing worth tuning.
- Tools — giving the assistant something to do.
- What is not built — read this before you plan around a feature.