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 refuses those outright, 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, the instructions it follows, the line it opens with, and how it hears, speaks and thinks. display_name is the only required field — everything else can be added later with a PATCH. The body uses the same shape GET /v2/agents/{agent_id} returns, so what you send is what you will read back.

Anything in {braces} in the prompt or the greeting 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 '{
    "display_name": "Appointment Reminder",
    "summary": "Confirms tomorrow'"'"'s appointment and offers to reschedule.",
    "category": "conversation",
    "labels": ["reminders"],
    "greeting_message": "Hi, am I speaking with {callee_name}?",
    "prompts": {
      "conversation": {
        "system": "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."
      }
    },
    "configuration": {
      "runtime": {
        "speech_recognition": {
          "provider": "deepgram",
          "model": "nova-2",
          "language": "en"
        },
        "voice_synthesis": {
          "provider": "cartesia",
          "voice_id": "a0e99841-438c-4a64-b679-ae501e7d6091",
          "model": "sonic-3",
          "speed": 1.0
        },
        "language_model": {
          "provider": "azure",
          "model": "azure/gpt-4o-mini",
          "config": { "max_tokens": 150, "temperature": 0.2 }
        }
      }
    }
  }'

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

  • version is 2, not 1. A new agent starts at version 2, and every successful update increments it. Your body is applied exactly as a PATCH would apply it, so a field means the same thing on create as on update.
  • variables is read from the text. It lists the placeholders the prompt and the greeting actually contain, as plain names, every time the agent is read. To add a variable, write it into the text.
  • What you did not send is filled in. The policy objects under configuration and the flat conversation_config come back with defaults; change any of them later with a PATCH that sends only that control.

A field this API does not know, at the top level of the body, is refused with 422 and error 1001 naming it. Inside configuration a provider's own options are passed through, so a misspelled key at that depth is not caught — read the agent back with GET /v2/agents/{agent_id} to confirm what was stored.

Creating an agent takes longer than most requests, and configuring extraction can need a language-model call, so allow this request up to two minutes before your client gives up — not the usual 15 seconds.

Deploy it

Placing a call does not check the agent's status: a DRAFT agent can already be dialled in step 4. What ACTIVE does is mark the agent live and lock its configuration, so nothing edits a running agent by accident. 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/6c6f4cc3-f56c-479d-8422-7f76694daa29" \
  -H "Authorization: Bearer $GRAINE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ACTIVE" }'

The lifecycle is DRAFT → ACTIVE → INACTIVE → ARCHIVED. An ACTIVE agent cannot be edited or archived: switch it off with {"status": "INACTIVE"} on its own, make your change, and set it back to ACTIVE. Archiving it with DELETE /v2/agents/{agent_id} needs INACTIVE first too, or the request returns 409 with error 1201. Status values come back uppercase.

Place the call

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

variables fills the {tokens} in the prompt and the greeting. metadata is merged over variables, so a key present in both takes the metadata value. An app talking to the same agent sends the same names through its SDK — Send your user's details to the agent.

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

Five 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": "scheduled", "deferred_by_calling_guard": trueThe agent's calling window was shut; the call rings at scheduled_at, inside the window. Do not resend it.
202"status": "scheduled", "reason": "waiting_for_a_free_line"Every line was busy, so the call is queued and dials the moment one frees. Do not resend it.
202"status": "queued", "dispatch": "pending_confirmation"The dial was sent, but its confirmation 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 and the call could not be queued for one. It 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 usual stored status for a voicemail pickup, with answered_by_voicemail set.
canceledCancelled before it dialled, or stopped with POST /v2/calls/{id}/stop. Note the single l.
stoppedKept for compatibility; no call placed today ends in it.
errorAn error ended the call.
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. A call the person answers but the agent never joins ends failed, with an error.message that begins agent_unavailable.

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 — usually set on a no-answer row.

Where to go next

  • Fetch the audio — GET /v2/calls/{execution_id}/recording returns the URL, or a 302 straight to the file with ?redirect=true. The audio itself never comes through the API.
  • Dial a list — POST /v2/campaigns takes the contacts as JSON or a CSV, with the calling policy — caller IDs, calling hours, retries and follow-ups — in the same request. Campaigns can be paused, resumed and cancelled.
  • Filter by agent — GET /v2/agents/{agent_id}/executions returns the same execution objects, narrowed to one agent, including calls it served as the variant in an A/B test.
  • Watch your spend — GET /v2/usage aggregates calls, connected minutes and cost over a date range.

On this page