Graine AI

Quickstart

Five requests from an API key to a completed phone call — create an agent, deploy it, dial, and read back the transcript-bearing execution.

By the end of this page you will have made a real phone call and read back what happened on it. Every request below is complete and copy-pasteable; every response is the shape the API actually returns.

Set your key once so the rest of the page works verbatim:

export GRAINE_KEY="gat_7f3c9a21b4e85d0c6a19f2734b8e5c60"
export GRAINE_API="https://api.graine.ai/v2"

Check your key

API keys start with gat_ and travel in an Authorization: Bearer header — the only form /v2 accepts. GET /v2/user/me is the cheapest way to confirm a key works, and it tells you the two numbers you will care about later: how many calls you may run at once, and how much credit is left.

curl "$GRAINE_API/user/me" \
  -H "Authorization: Bearer $GRAINE_KEY"

A 401 with error 1101 means you sent a browser session token rather than an API key. /v2 rejects those locally, before any network call, so a leaked dashboard session can never drive the API.

POST /v2/api-keys mints further keys, but it requires the keys:write scope, which is denied by default — no key already in circulation can mint another one until an account admin grants it. Your first key therefore comes from your account admin, not from this API.

Create an agent

An agent is a name, a system prompt, a welcome line, and a tasks array — the raw pipeline configuration that says which transcriber, model and voice to run. name and a non-empty tasks list are the only required fields.

Anything in {braces} in the prompt or the welcome message becomes a variable you fill per call in step 4.

curl -X POST "$GRAINE_API/agents" \
  -H "Authorization: Bearer $GRAINE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Appointment Reminder",
    "description": "Confirms tomorrow'"'"'s appointment and offers to reschedule.",
    "agent_type": "conversation",
    "welcome_message": "Hi, am I speaking with {callee_name}?",
    "prompt": "You are a scheduling assistant for {company_name}. Confirm that the customer still wants their appointment tomorrow. If they cannot make it, offer to reschedule. Keep every reply to one or two short sentences, and end the call once the appointment is confirmed or moved.",
    "tags": ["reminders"],
    "tasks": [
      {
        "task_type": "conversation",
        "toolchain": {
          "execution": "parallel",
          "pipelines": [["transcriber", "llm", "synthesizer"]]
        },
        "tools_config": {
          "input":  { "format": "wav", "provider": "custom_telephony" },
          "output": { "format": "wav", "provider": "custom_telephony" },
          "transcriber": {
            "provider": "deepgram",
            "model": "nova-2",
            "language": "en",
            "stream": true,
            "endpointing": 200,
            "encoding": "linear16",
            "sampling_rate": 16000,
            "task": "transcribe"
          },
          "synthesizer": {
            "provider": "cartesia",
            "stream": true,
            "audio_format": "wav",
            "buffer_size": 350,
            "caching": true,
            "provider_config": {
              "voice_id": "a0e99841-438c-4a64-b679-ae501e7d6091",
              "model": "sonic-3",
              "speed": 1.0
            }
          },
          "llm_agent": {
            "agent_type": "simple_llm_agent",
            "agent_flow_type": "streaming",
            "llm_config": {
              "provider": "azure",
              "model": "azure/gpt-4o-mini",
              "family": "openai",
              "agent_flow_type": "streaming",
              "max_tokens": 150,
              "temperature": 0.2
            }
          }
        }
      }
    ]
  }'

Two things in that response are worth pointing at now, because they surprise people later:

  • variables comes back as names, not objects. They are extracted from the prompt and the welcome message. The create body takes objects ([{"name": "callee_name"}]); reads hand back ["callee_name"].
  • conversation_config is empty on create. It is an update-only field — send it on a PATCH once the agent exists.

tasks is echoed from the agent service's stored copy, which fills in its own defaults inside each task. Read the agent back with GET /v2/agents/{agent_id} for the exact configuration that will run.

If a task carries extraction_details without an extraction_json, the agent service generates the schema with a language-model call during create, and this request is allowed up to 45 seconds instead of the usual 15. Set your client timeout accordingly.

Deploy it

A DRAFT agent cannot place calls. Move it to ACTIVE with a PATCH — only the fields you send are changed, and every successful update increments version.

curl -X PATCH "$GRAINE_API/agents/agent_5b7d21e9c8a34f60" \
  -H "Authorization: Bearer $GRAINE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ACTIVE" }'

The lifecycle is DRAFT → ACTIVE → INACTIVE → ARCHIVED, and it is a one-way gate in one direction: an ACTIVE agent cannot be edited or archived. Set it back to INACTIVE first, or the request returns 409 with error 1201. Status values are uppercase on the wire.

Place the call

POST /v2/calls dials immediately. to_number is E.164; omit from_number to let the telephony service pick a caller ID registered to your organization.

variables fills the {tokens} in the prompt and welcome message. metadata is merged over variables, so a key present in both takes the metadata value.

curl -X POST "$GRAINE_API/calls" \
  -H "Authorization: Bearer $GRAINE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_5b7d21e9c8a34f60",
    "to_number": "+14155550123",
    "variables": {
      "callee_name": "Priya",
      "company_name": "Northgate Dental"
    },
    "metadata": {
      "crm_record": "cust_88213"
    },
    "record": true,
    "timeout_seconds": 30
  }'

Three outcomes are worth handling explicitly:

StatusBodyWhat to do
201"status": "queued"Normal. Poll the execution.
202"status": "scheduled"Stored and armed for scheduled_at.
202"status": "queued", "dispatch": "pending_confirmation"The dial reached the telephony service but the acknowledgement did not come back in time. The call may be live. Poll — never redial.

A 429 here with the message "Account concurrency limit reached" is not the request rate limit — it means every line your organization is allowed to run at once is busy. The dial waits a few seconds for a free line before giving up, and the response carries Retry-After: 5.

Read the execution

The same execution_id answers from "scheduled" through "ringing" and "in-progress" to a terminal status. Read status and is_terminal; you never have to branch on which phase the call is in.

curl "$GRAINE_API/calls/8f2a1c7e-5d34-4b19-9c60-1a7f0b2e4d88" \
  -H "Authorization: Bearer $GRAINE_KEY"

Polling

Poll the execution until is_terminal is true. The terminal statuses are:

StatusMeaning
completedThe conversation ran to its end.
failedThe call could not be completed.
busyThe line was busy.
no-answerNobody picked up. Also the stored status for a voicemail pickup, with answered_by_voicemail set.
canceledCancelled before it dialled. Note the single l.
stoppedHung up on request via POST /v2/calls/{id}/stop.
errorThe pipeline errored.
call-disconnectedThe leg dropped.
balance-lowStopped because the wallet could not fund it.

Everything else — queued, scheduled, rescheduled, initiated, ringing, in-progress — is in flight and is_terminal is false.

Unlike the older dashboard API, GET /v2/calls shows in-flight calls by default. Omitting status returns everything; pass it only to narrow the result. It takes a comma-separated list, tolerates both spellings of the hyphenated values (no_answer / no-answer), and also accepts voicemail, which is not a status but a flag on no-answer rows.

Where to go next

  • Fetch the audioGET /v2/calls/{execution_id}/recording returns the URL, or a 302 straight to the file with ?redirect=true. The bytes are never proxied through the API.
  • Dial a listPOST /v2/batches takes contacts as JSON, and POST /v2/batches/upload takes a CSV. Batches can be scheduled, paused, resumed and cancelled.
  • Filter by agentGET /v2/agents/{agent_id}/executions returns the same execution objects, narrowed to one agent, including calls that agent ran as a resolved A/B variant.
  • Watch your spendGET /v2/usage aggregates calls, connected minutes and cost over a date range.

On this page