Quickstart with curl

Create, voice, test and call a hosted agent over the REST API, with every request as a copy-pasteable curl.

This page takes you from an empty workspace to a hosted agent that has a voice, has answered a text test, and can place a call, using nothing but curl and jq. Every request is literal: set two environment variables and paste the blocks in order.

If you would rather let an AI agent drive Skysay over MCP, start with the MCP quickstart instead. Both use the same keys, scopes and routes.

Before you start

You need a Skysay workspace, curl, and jq.

Create an API key in the dashboard

Open API Keys in the workspace and create a key. Select these scopes (the dialog groups them by namespace):

ScopeUsed for
account:readStep 1, reading your workspace and project
agents:readStep 2, the voice catalogue, and reading the agent back
agents:writeSteps 3 to 6, creating, configuring and text-testing the agent
numbers:readStep 7, finding a caller number
onboarding:readStep 7, checking outbound is cleared
calls:writeStep 7, placing a call
calls:readStep 8, reading the call
recordings:readStep 8, reading the transcript
wallet:readStep 9, checking the balance

The all-read bundle does not cover the writes, and all-write does not cover the reads. Pick the leaves above, or all for a key you keep to yourself. The key (sky_…) is shown once; copy it now. Authentication & keys covers bundles, clamping and revocation.

export SKYSAY_API_KEY="sky_your_key_here"
export SKYSAY_BASE_URL="https://api.skysay.ai"

The key already knows which workspace it belongs to, so no request on this page sends organization_id, in a query string or in a body. When it is left out, the key's own workspace is used; naming a different workspace answers 403. project_id is never filled in for you: the writes that need it name it explicitly.

1. Read your workspace and project

GET /v1/workspace-context · scope account:read

curl -s "$SKYSAY_BASE_URL/v1/workspace-context" \
  -H "Authorization: Bearer $SKYSAY_API_KEY"
{
  "organization": { "id": "org_7Hq2LmX9pRt4VwZ1", "name": "Acme Dental" },
  "project_count": 1,
  "projects": [
    { "environment": "sandbox", "id": "proj_4Nc8TkQ2sW6yBd3F", "name": "Default project" }
  ],
  "projects_page": { "has_more": false, "limit": 50, "next_offset": null, "offset": 0, "returned_count": 1 }
}

Keep both ids. Creating an agent and placing a call name the project in the request body, and the outbound check in step 7 has the workspace id in its path:

export ORG_ID=$(curl -s "$SKYSAY_BASE_URL/v1/workspace-context" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" | jq -r '.organization.id')
export PROJECT_ID=$(curl -s "$SKYSAY_BASE_URL/v1/workspace-context" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" | jq -r '.projects[0].id')

2. Choose a voice from the catalogue

GET /v1/agent-catalog · scope agents:read (or coverage:read)

The catalogue lists every selectable speech-to-text (stt), model (llm) and text-to-speech (tts) component, with its price and, for TTS, its voices. Filter it to English voices you can run on platform-managed billing:

curl -s "$SKYSAY_BASE_URL/v1/agent-catalog?component=tts&language=en&metered_only=true" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '.entries[] | {provider, model, cost_per_min_micro_usd, voice_count,
                      voices: [.voices[:3][] | {id, name}]}'
{
  "provider": "azure",
  "model": "neural",
  "cost_per_min_micro_usd": 17100,
  "voice_count": 60,
  "voices": [
    { "id": "en-US-AdamMultilingualNeural", "name": "Adam" },
    { "id": "en-US-AIGenerate1Neural", "name": "AIGenerate1" },
    { "id": "en-US-AIGenerate2Neural", "name": "AIGenerate2" }
  ]
}

Prices are in micro-USD per minute: 17100 is $0.0171 a minute for that component. An entry with voices_truncated: true lists fewer voices than its voice_count. Leave out component to get the stt and llm rows as well, plus the conversation_engines block described in Conversation engines. The unfiltered response is several hundred kilobytes, so filter it with jq rather than reading it whole. There is no separate voices endpoint.

This guide uses Deepgram Nova-3 to transcribe, Gemini 3.5 Flash Lite to reply, and the Azure voice en-US-AvaNeural to speak. Voice library explains how to compare voices by ear.

3. Create a hosted agent

POST /v1/agents · scope agents:write

curl -s -X POST "$SKYSAY_BASE_URL/v1/agents" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "'"$PROJECT_ID"'",
    "mode": "hosted",
    "name": "Acme Dental receptionist",
    "language": "en",
    "system_prompt": "You are a friendly receptionist for Acme Dental. Answer questions about opening hours (Mon-Fri 9-17) and offer to book an appointment.",
    "begin_message": "Hi, thanks for calling Acme Dental. How can I help?"
  }'

The response is 201 with the new agent:

{
  "id": "agent_9sLq3VbN7xKe2TmR",
  "agent_endpoint_id": "ae_5RwP8yHc2QdLz6Jn",
  "mode": "hosted",
  "status": "active",
  "language": "en",
  "begin_message": "Hi, thanks for calling Acme Dental. How can I help?",
  "voice_stack": {},
  "...": "..."
}
export AGENT_ID="agent_9sLq3VbN7xKe2TmR"   # the id from your response

voice_stack is empty: the agent cannot speak yet.

4. Give the agent a voice stack

PATCH /v1/agents/{agent_id}/voice-stack · scope agents:write

curl -s -X PATCH "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/voice-stack" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transcriber": { "provider": "deepgram", "model": "nova-3", "language": "en" },
    "model":       { "provider": "gemini",   "model": "gemini-3.5-flash-lite" },
    "voice":       { "provider": "azure",    "model": "neural", "voice_id": "en-US-AvaNeural" }
  }'

The response is 200 with the saved stack, its estimated cost and latency, and any warnings:

{
  "agent_id": "agent_9sLq3VbN7xKe2TmR",
  "voice_frontend": "cascade",
  "voice_label": "en-US-AvaNeural",
  "selectable": true,
  "warnings": [],
  "cost": {
    "currency": "USD",
    "unit": "micro_usd_per_minute",
    "stt_micro_usd_per_min": 30000,
    "llm_micro_usd_per_min": 3500,
    "tts_micro_usd_per_min": 17100,
    "total_micro_usd_per_min": 50600
  },
  "latency": { "stt_ms": 250, "llm_ms": 520, "tts_ms": 568, "total_ms": 1338 },
  "...": "..."
}

A voice without a voice_id is refused with 400, reason "voice_id_missing" and a message such as "Choose a voice for English.". Turn-taking, languages, ambience and delivery settings are saved on this same route; see REST endpoints. GET on the same path (scope agents:read) reads the stack back.

5. Test the agent

POST /v1/agents/{agent_id}/chat-turns · scope agents:write

A text chat runs the agent's real configuration, model and tools, without audio, without a call record, and without charging your wallet. The client keeps the transcript. Ask for the opening line with an empty transcript and caller_input: null:

curl -s -X POST "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/chat-turns" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"caller_input": null, "transcript": []}'

Then send each caller line together with everything said so far:

curl -s -X POST "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/chat-turns" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "caller_input": "Are you open on Saturday?",
    "transcript": [
      { "role": "agent", "text": "Hi, thanks for calling Acme Dental. How can I help?" }
    ]
  }'
{
  "agent_id": "agent_9sLq3VbN7xKe2TmR",
  "snapshot": "published",
  "model": "gemini-3.5-flash-lite",
  "reply": {
    "role": "agent",
    "text": "We are actually closed on Saturdays. Our opening hours are Monday through Friday from 9 AM to 5 PM. Would you like to book an appointment for one of those days?",
    "truncated": false
  },
  "ended": false,
  "ended_reason": null,
  "events": [],
  "metering": { "mode": "platform_funded", "components": ["llm"], "model_calls": 1, "input_tokens": 127, "output_tokens": 36 }
}

To hear the agent, open it under Agents in the workspace and start a browser test. It uses your welcome browser credit or purchased funds, needs no phone number, and is capped at 180 seconds. The API behind it, POST /v1/agents/{agent_id}/browser-sessions (scopes agents:write and calls:write), returns credentials for a WebRTC client rather than audio, so curl alone cannot play the call. See Test an agent.

6. Optional: add a conversation workflow

A hosted agent runs from its instructions and opening line alone, so you can skip this step. Add a workflow only when you want the call to follow fixed steps. It takes two requests, in this order: publishing before a draft exists answers 404 with "This agent has no workflow yet. Create one first."

Create the draft · POST /v1/agents/{agent_id}/workflow · scope agents:write

curl -s -X POST "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/workflow" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"template_id": "blank"}'
{
  "workflow_id": "wf_6JtY2mQ8cVx4Hb9K",
  "revision": 0,
  "source": "blank",
  "validation": { "ok": true, "errors": [], "warnings": [] },
  "draft": {
    "entry_step": "greet",
    "steps": [
      { "id": "greet", "type": "say", "prompt": "Hello, thanks for calling. How can I help you today?", "next": "end_call" },
      { "id": "end_call", "type": "end", "farewell": "Thank you for calling. Goodbye." }
    ],
    "...": "..."
  }
}

template_id is one of blank, payment_reminder, payment_reminder_variables, customer_support or receptionist. You can send a brief in plain language instead, to have the draft generated. The blank draft greets the caller and hangs up. Edit it in the workspace's workflow editor or with PATCH /v1/agents/{agent_id}/workflow before you publish it.

Publish the draft · POST /v1/agents/{agent_id}/workflow/publish · scope agents:write

curl -s -X POST "$SKYSAY_BASE_URL/v1/agents/$AGENT_ID/workflow/publish" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"base_revision": 0}'
{
  "agent_id": "agent_9sLq3VbN7xKe2TmR",
  "workflow_id": "wf_6JtY2mQ8cVx4Hb9K",
  "version": 1,
  "published_at": "2026-09-24T09:00:00Z",
  "compiled_hash": "9686b5e0…",
  "contract": { "...": "..." }
}

base_revision is the draft revision you reviewed. If the draft changed since then, publish answers 409 and carries the current draft under current. A draft with validation errors answers 400. A draft with warnings answers 400 listing their codes; send them back in acknowledged_warning_codes to publish anyway.

Once an agent has a workflow, the text test in step 5 answers 400 with "This AI agent has a conversation flow; chat with it through the flow's Chat test." Test a workflow with its own Chat test, described in Agent workflows.

7. Place an outbound call

POST /v1/calls · scope calls:write

A real phone call needs three things: outbound cleared for your workspace, a number to call from, and purchased funds, because browser credit does not pay for phone calls. Check the first two before you dial.

Check outbound is cleared · GET /v1/organizations/{organization_id}/capabilities · scope onboarding:read

curl -s "$SKYSAY_BASE_URL/v1/organizations/$ORG_ID/capabilities" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '.capabilities | {status, countries, all_countries, approved_profiles, customer_visible_message}'
{
  "status": "active",
  "countries": ["GB", "US"],
  "all_countries": false,
  "approved_profiles": ["customer_outreach", "business_support"],
  "customer_visible_message": "Outbound is active within your approved envelope."
}

Calls are allowed only while status is active, and only to the listed countries (or anywhere, when all_countries is true). To request outbound, see Policy & outbound.

Find a caller number · GET /v1/numbers · scope numbers:read

curl -s "$SKYSAY_BASE_URL/v1/numbers" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '.[] | select(.status == "active") | {id, e164, country, regulatory_status}'
{ "id": "num_2XbK7pQw4ZrT9mNc", "e164": "+12025550143", "country": "US", "regulatory_status": "not_required" }

With no active number, request one first.

Place the call:

curl -s -X POST "$SKYSAY_BASE_URL/v1/calls" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: quickstart-call-1" \
  -d '{
    "project_id": "'"$PROJECT_ID"'",
    "agent_id": "'"$AGENT_ID"'",
    "from_phone_number_id": "num_2XbK7pQw4ZrT9mNc",
    "to_phone_number": "+12025550188",
    "country": "US",
    "purpose": "appointment",
    "user_authorization": {
      "authorized_by": "[email protected]",
      "reason": "Patient asked for a callback about their appointment"
    }
  }'

A call the platform accepted answers 201 with the call record:

{
  "id": "call_8GmT3xRk5VqW2nYp",
  "status": "queued",
  "direction": "outbound",
  "agent_id": "agent_9sLq3VbN7xKe2TmR",
  "country": "US",
  "purpose": "appointment",
  "policy_decision_id": "pd_4LcN9sHq2WxB7tKd",
  "...": "..."
}
export CALL_ID="call_8GmT3xRk5VqW2nYp"   # the id from your response

Read status before assuming a phone is ringing:

statusMeaning
queuedAdmitted; the call is being dialled.
needs_reviewHeld for operator review; nothing has been dialled yet.
policy_blockedRefused by policy; nothing was dialled and nothing is charged.
live_blockedAccepted but live calling is switched off for the platform right now; nothing was dialled.

user_authorization records who approved this call and why. Without both fields the call is held for review. purpose is free text; the documented values are listed in the OpenAPI schema. Retrying with the same Idempotency-Key returns the original call instead of dialling twice. Reusing the key for a different request answers 409. More fields (external_reference, call_context, runtime_config) are covered in Customer applications.

8. Read the call and its transcript

Read one call · GET /v1/calls/{call_id} · scope calls:read

curl -s "$SKYSAY_BASE_URL/v1/calls/$CALL_ID" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '{id, status, direction, ended_reason, reason_detail, termination_cause, recording_status, created_at, updated_at}'
{
  "id": "call_8GmT3xRk5VqW2nYp",
  "status": "completed",
  "direction": "outbound",
  "ended_reason": "agent_completed",
  "reason_detail": "",
  "termination_cause": "connected",
  "recording_status": "available",
  "created_at": "2026-09-24T09:05:12Z",
  "updated_at": "2026-09-24T09:06:03Z"
}

Browser tests are calls too, with direction: "web_test", so you can read the browser test you started from the workspace the same way.

List recent calls · GET /v1/calls · scope calls:read

curl -s -D - "$SKYSAY_BASE_URL/v1/calls?limit=20" \
  -H "Authorization: Bearer $SKYSAY_API_KEY"

The body is a JSON array, newest first. Paging is in the response headers: X-Total-Count, X-Has-More and X-Next-Cursor. Pass the cursor back as ?before=<X-Next-Cursor> for the next page. limit defaults to 100 and is capped at 500.

Read the transcript · GET /v1/calls/{call_id}/transcript · scope recordings:read

curl -s "$SKYSAY_BASE_URL/v1/calls/$CALL_ID/transcript" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '{call_id, transcript_status, text, turns: [.turns[] | {sequence, speaker, text}]}'
{
  "call_id": "call_8GmT3xRk5VqW2nYp",
  "transcript_status": "available",
  "text": "agent: Hi, thanks for calling Acme Dental. How can I help?\ncaller: Are you open on Saturday?\nagent: We are closed on Saturdays…",
  "turns": [
    { "sequence": 1, "speaker": "agent", "text": "Hi, thanks for calling Acme Dental. How can I help?" },
    { "sequence": 2, "speaker": "caller", "text": "Are you open on Saturday?" }
  ]
}

Recordings, waveforms and retention are covered in Read call evidence. To be told when a call ends instead of polling, subscribe to webhooks.

9. Check the wallet

GET /v1/wallet · scope wallet:read

curl -s "$SKYSAY_BASE_URL/v1/wallet" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  | jq '{currency, available_minor, promotional_available_minor, total_available_minor,
         reserved_minor, spent_minor, low_balance: .low_balance | {status, needs_topup, next_action}}'
{
  "currency": "USD",
  "available_minor": 2500,
  "promotional_available_minor": 250,
  "total_available_minor": 2750,
  "reserved_minor": 0,
  "spent_minor": 1200,
  "low_balance": {
    "status": "ready",
    "needs_topup": false,
    "next_action": "Wallet can cover projected recurring and reserved costs."
  }
}

Amounts are in minor units of currency, so 2500 is $25.00. available_minor is purchased funds, the only balance that pays for phone calls. promotional_available_minor is browser-test credit. See Billing.

When a request fails

Every error is JSON with an error field, and usually a message:

{ "error": "ForbiddenError", "message": "missing required scope: wallet:read" }

A 403 naming a scope means the key needs that scope added. A 404 on a by-id route means the id is not in your workspace. Errors lists every status and body shape.

Next steps

On this page