# Agents & channels

> Four read-only endpoints that tell your backend what this workspace has — which agents exist, which senders are connected, what your key can do, and the machine-readable contract.

Source: https://docs.amomic.in/api/discovery

---

Four endpoints on this API do not do anything — they tell you about the workspace you are pointed at. They exist because of a gap the console cannot fill.

Everything else on this API takes an identifier you have to have got from somewhere. `POST /messages` takes a `sender_id`. `GET /conversations` takes an `agent_id` filter. `POST /calls` takes an `agent_id` to assert against. In the console those are things you click. In a deployment script running at 3 a.m. against a customer's workspace, there is no console open and nobody to copy a value out of one.

| Endpoint | Needs | Tells you |
|---|---|---|
| `GET /api/v1/agents` | `agents:read` | Which agents exist, and which are live on web chat |
| `GET /api/v1/channels` | Any valid key | Which channels this deployment carries, and which senders are connected |
| `GET /api/v1/ping` | Any valid key | Which workspace, key, environment, and scopes you are holding |
| `GET /api/v1/openapi.json` | **Nothing** | The machine-readable contract for every route |

None of them has a side effect. All four are safe to call on every process start.

## Listing agents

```endpoint
GET /api/v1/agents
```

Requires the `agents:read` scope, which the **Create an API key** dialog ticks by default. No query parameters.

```json title="200 OK"
{
  "items": [
    {
      "agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH5",
      "name": "Veloria Support",
      "status": "live",
      "channels": ["web"]
    },
    {
      "agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4",
      "name": "AMC Renewals",
      "status": "draft",
      "channels": []
    }
  ],
  "next_cursor": null
}
```

| Field | Type | Notes |
|---|---|---|
| `agent_id` | string | The handle you pass everywhere else. Treat it as an opaque string — copy it from here, do not construct it and do not parse it. |
| `name` | string | What the agent is called in the console. Falls back to `Assistant` when unset. |
| `status` | string | `live` or `draft`. Read the warning below: this is about web chat, not about whether the agent answers anywhere. |
| `channels` | string[] | `["web"]` when `status` is `live`, otherwise `[]`. |

Archived agents are left out. The newest agent comes first.

Nothing else is returned. There is no persona, no model configuration, no knowledge base list, no version history, and no deployment detail — those live in the console and have no public REST equivalent. This endpoint answers "which agents exist and what are they called", and that is its whole job.

::: warning `status` and `channels` describe web chat only
`status` is `live` while the agent has a live web chat deployment, and `draft` otherwise. `channels` is derived from it: `["web"]` or `[]`.

So an agent that answers WhatsApp and takes phone calls, but has no live web chat widget, reports `draft` and `[]`. Never read `draft` as "this agent is not answering customers". Saved agent edits are served immediately on every channel the agent is deployed to, whatever this field says.

For what the workspace can send on, call [`GET /channels`](#what-this-deployment-can-do). For where a specific agent is deployed, the agent's **Deployments** tab in the console is the authority. See [Deployments](/build/deployments).
:::

### The envelope, and why it never paginates

The response uses the same `{items, next_cursor}` shape as [conversations](/api/conversations), but `next_cursor` is always `null` and there is no `limit` or `cursor` parameter. You get every agent in the workspace in one response.

That is deliberate — a workspace has agents in the tens, not the thousands — and it means a generic pagination helper works here without a special case: one page, a `null` cursor, done. [Pagination](/api/pagination) has that helper written out.

### What you use `agent_id` for

Two places, and they behave very differently:

- **`GET /conversations?agent_id=…`** filters the list to one agent's conversations. A straightforward filter.
- **`POST /calls` with `agent_id`** is an **assertion, not a selection**. The agent that speaks on an outbound call is resolved from the caller-ID number, not from this field. Sending an `agent_id` that is not the one deployed to that number returns `412 agent_number_mismatch`, and `details.deployed_agent_id` names the agent that actually is. See [Calls](/api/calls).

::: tip Resolve names to ids once, at startup
If your configuration names agents by their human name — `"Support Assistant"` — resolve it to an `agent_id` when your process starts, and cache it. Doing it per request spends a rate-limit slot on a value that changes about once a quarter. Doing it never means someone renames the agent in the console and your integration keeps working, which is the point.
:::

## What this deployment can do

```endpoint
GET /api/v1/channels
```

Requires a valid key and no particular scope. No query parameters.

```json title="200 OK"
{
  "message_channels": ["whatsapp"],
  "call_channels": ["voice"],
  "senders": [
    {
      "channel": "whatsapp",
      "sender_id": "was_9f2c41ab77de1063c8a4b512",
      "display_number": "+918047261935",
      "verified_name": "Veloria Home",
      "is_default": true,
      "status": "live"
    }
  ]
}
```

| Field | Type | Notes |
|---|---|---|
| `message_channels` | string[] | The channels this deployment can send messages on. |
| `call_channels` | string[] | The channels it can place calls on. |
| `senders` | object[] | Every connected sender we could enumerate. Empty when nothing is connected. |
| `senders[].channel` | string | Which channel this sender belongs to. |
| `senders[].sender_id` | string | The value you pass as `sender_id` on [`POST /messages`](/api/messages). Opaque — copy it. |
| `senders[].display_number` | string | The number customers see. |
| `senders[].verified_name` | string | The business name approved for this sender. |
| `senders[].is_default` | boolean | `true` on the workspace's default sender. When you omit `sender_id`, a default sender is used; with none marked, the first by number. |
| `senders[].status` | string | `configuring`, `live`, or `disabled`. Plan to send only from a `live` sender. |

Senders are listed default first, then by number.

### Two different questions

The two arrays and the sender list answer different things, and conflating them is a real source of confusion:

- **`message_channels` and `call_channels` are about the deployment.** They tell you which capabilities this service carries at all. Asking for a channel that is not in these lists returns `412 failed_precondition` with `details.reason` of `unsupported_channel` and `details.supported` naming what is available.
- **`senders` is about the workspace.** `whatsapp` can be in `message_channels` while `senders` is empty, which means the capability exists and this workspace has not connected a number to it. A send in that state returns `412` with `channel_not_connected`.

Both are configuration states, not transient ones. Retrying either is pointless — see [Errors](/api/errors).

::: warning Sender enumeration is best-effort
Building the `senders` array means asking each channel provider what it has. If one of those lookups fails, the failure is logged and that channel is **skipped** — the endpoint still returns `200` with whatever else it found.

A sender you expected and did not get is therefore ambiguous: it might not be connected, or its provider might have been unreachable for that one second. Never treat an absent sender as proof of an absent configuration, and never write a startup check that hard-fails on it. The channel arrays are the more stable signal; the sender list is a convenience.
:::

## Checking your key

```endpoint
GET /api/v1/ping
```

Requires a valid key and no particular scope. No parameters, no body, no side effects.

::: codegroup

```bash cURL
curl https://services.amomic.in/api/v1/ping \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```javascript Node.js
const response = await fetch("https://services.amomic.in/api/v1/ping", {
  headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
});
const ping = await response.json();
if (!response.ok) throw new Error(`${ping.error.code}/${ping.error.details.reason}`);
console.log(ping.workspace_id, ping.key_name, ping.scopes);
```

```python Python
import os

import requests

response = requests.get(
    "https://services.amomic.in/api/v1/ping",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    timeout=15,
)
ping = response.json()
if not response.ok:
    raise RuntimeError(f"{ping['error']['code']}/{ping['error']['details'].get('reason')}")
print(ping["workspace_id"], ping["key_name"], ping["scopes"])
```

:::

```json title="200 OK"
{
  "ok": true,
  "workspace_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4",
  "key_name": "Service desk (production)",
  "environment": "live",
  "scopes": ["agents:read", "conversations:read", "messages:send"]
}
```

| Field | Type | Notes |
|---|---|---|
| `ok` | boolean | Always `true`. If the key were bad you would have a `401` instead. |
| `workspace_id` | string | The workspace this key belongs to. The same value arrives as `tenant_id` on every webhook. |
| `key_name` | string | The name the key was given in the console. Useful when three keys look identical in a secret store. |
| `environment` | string | The key's environment. Keys created in the console are `live`. |
| `scopes` | string[] | Sorted. Exactly what this key may do. |

This is the first call every integration should make, and it is worth wiring into more than a one-off:

- **On deploy.** A `200` here proves the secret reached the process, the workspace is in good standing, and the key is not revoked. Three of the most common launch failures are ruled out before any customer-visible request.
- **When a `403` surprises you.** `scopes` is the authoritative list. Comparing it against the scope named in `details.required_scope` turns "permission denied" from a mystery into a one-line diff. See [Scopes](/api/scopes).
- **When a `401` surprises you.** `details.reason` says which check failed: `missing_api_key`, `malformed_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key` (a rolled key past its 24 hours) or `wrong_environment`. See [Authentication](/api/authentication).

::: note Ping costs a request
It is cheap but not free — it spends one of your [120 per minute](/api/rate-limits), and its response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining` like any other success. That makes it a fine way to check your remaining budget, and a bad thing to put in a one-second health-check loop.
:::

## The machine-readable contract

```endpoint
GET /api/v1/openapi.json
```

**No key required.** This is the only route on `/api/v1` that answers an unauthenticated request, because a contract is not a secret and needing a credential to read one makes tooling harder for no security benefit.

```bash
curl https://services.amomic.in/api/v1/openapi.json -o amomic-openapi.json
```

It returns an OpenAPI 3.1 document containing:

- **`info`** — title `Amomic-AI API`, and a description that repeats the two auth header forms and the no-CORS rule.
- **`servers`** — the host you fetched it from. Every path in the document already starts with `/api/v1`. Call the host over `https://` whatever scheme this entry shows; the copy published with these docs at `https://docs.amomic.in/openapi.json` already says `https://services.amomic.in`.
- **`paths`** — every `/api/v1` route and nothing else. Console-only routes are filtered out of the document entirely, so what you see is exactly the public surface.
- **`components.schemas`** — the request and response models. The list endpoints declare their `items` without an item schema, so a generated client types them loosely; the item shapes are documented on each endpoint page.
- **`securitySchemes`** — `ApiKeyBearer` (HTTP bearer) and `ApiKeyHeader` (`X-Api-Key`), with both offered at the document level.

Two things to do with it:

**Generate a client.** There are no official SDKs in any language — deliberately, since the API is plain HTTP and JSON. If you want a typed client, generate one from this document rather than hand-rolling models that drift.

**Give it to a coding agent.** An agent that has the OpenAPI document plus these docs writes a working integration on the first attempt instead of inventing endpoints from a model's memory of "some WhatsApp API". [Use with coding agents](/agents-setup) has the setup for Claude Code, Codex, Cursor, and anything that speaks MCP — including a documentation MCP endpoint and a single-file corpus.

::: note The document describes shapes, not behaviour
An OpenAPI document can tell a generator that `POST /messages` takes a `to` and a `text`. It cannot tell it that a free-form `text` outside the 24-hour window fails, that `Idempotency-Key` must be derived rather than generated, or that `agent_id` on a call is an assertion. Those are the things that break integrations, and they are in these pages. Use both.
:::

## A startup check worth having

Fifteen lines that turn every configuration failure into one clear log line at boot instead of a confusing `412` during a customer's checkout.

::: codegroup

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";
const HEADERS = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` };

async function get(path) {
  const response = await fetch(`${BASE}${path}`, { headers: HEADERS });
  const body = await response.json();
  if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
  return body;
}

export async function preflight({ requiredScopes = ["messages:send"] } = {}) {
  // 1. Is the credential live, and is it the one we think it is?
  const ping = await get("/ping");
  console.info("amomic_ready", {
    workspace: ping.workspace_id,
    key: ping.key_name,
    environment: ping.environment,
  });

  const missing = requiredScopes.filter((s) => !ping.scopes.includes(s));
  if (missing.length) {
    throw new Error(`API key "${ping.key_name}" is missing scopes: ${missing.join(", ")}`);
  }

  // 2. Can this deployment send at all, and is a sender connected?
  const channels = await get("/channels");
  if (!channels.message_channels.includes("whatsapp")) {
    throw new Error("This deployment cannot send WhatsApp messages.");
  }

  const sender = channels.senders.find((s) => s.channel === "whatsapp" && s.is_default);
  if (!sender) {
    // Enumeration is best-effort — warn, do not crash the process.
    console.warn("amomic_no_default_sender", { senders: channels.senders.length });
  } else if (sender.status !== "live") {
    console.warn("amomic_sender_not_live", { sender_id: sender.sender_id, status: sender.status });
  }

  return { workspaceId: ping.workspace_id, senderId: sender?.sender_id ?? null };
}
```

```python Python
import logging
import os

import requests

BASE = "https://services.amomic.in/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}
log = logging.getLogger(__name__)


def _get(session: requests.Session, path: str) -> dict:
    response = session.get(f"{BASE}{path}", headers=HEADERS, timeout=15)
    body = response.json()
    if not response.ok:
        error = body["error"]
        raise RuntimeError(f"{error['code']}: {error['message']}")
    return body


def preflight(session: requests.Session, required_scopes=("messages:send",)) -> dict:
    # 1. Is the credential live, and is it the one we think it is?
    ping = _get(session, "/ping")
    log.info(
        "amomic_ready",
        extra={
            "workspace": ping["workspace_id"],
            "key": ping["key_name"],
            "environment": ping["environment"],
        },
    )

    missing = [s for s in required_scopes if s not in ping["scopes"]]
    if missing:
        raise RuntimeError(
            f"API key {ping['key_name']!r} is missing scopes: {', '.join(missing)}"
        )

    # 2. Can this deployment send at all, and is a sender connected?
    channels = _get(session, "/channels")
    if "whatsapp" not in channels["message_channels"]:
        raise RuntimeError("This deployment cannot send WhatsApp messages.")

    sender = next(
        (s for s in channels["senders"] if s["channel"] == "whatsapp" and s["is_default"]),
        None,
    )
    if sender is None:
        # Enumeration is best-effort — warn, do not crash the process.
        log.warning("amomic_no_default_sender", extra={"senders": len(channels["senders"])})
    elif sender["status"] != "live":
        log.warning(
            "amomic_sender_not_live",
            extra={"sender_id": sender["sender_id"], "status": sender["status"]},
        )

    return {
        "workspace_id": ping["workspace_id"],
        "sender_id": sender["sender_id"] if sender else None,
    }
```

:::

Note what it does and does not treat as fatal. A missing scope is fatal — it will fail every send, loudly, forever. A missing sender is a warning, because enumeration is best-effort and refusing to start over a transient provider lookup is worse than starting.

## Failures

```responses
[
  {"status": 200, "code": "", "reason": "", "description": "All four endpoints return 200 on success. `/agents` and `/channels` return an empty list rather than a 404 when there is nothing to list."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "Applies to `/agents`, `/channels`, and `/ping`. Key missing, malformed, unknown, revoked, expired after a roll, or from the wrong environment — `details.reason` distinguishes all six. `/openapi.json` never returns this."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended for billing. Every key in it is refused, including on `/ping`, until billing is restored."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "`/agents` only. The key lacks `agents:read`. `/channels` and `/ping` need no scope beyond a valid key."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "These endpoints count against the same 120 per minute as everything else. Cache their answers; they change on the timescale of console edits, not requests."}
]
```

There is no `412` on any of them. `GET /channels` in particular is designed to be the thing you call *instead of* discovering a missing capability through a `412` on a real send.

## Related

::: cards

::: card Messages
[POST /messages](/api/messages) — where the `sender_id` from `/channels` gets used, and what happens when you omit it.
:::

::: card Calls
[POST /calls](/api/calls) — where `agent_id` is an assertion rather than a selection, and why that matters.
:::

::: card Scopes
[The eleven accepted scopes](/api/scopes) — what `/ping` is showing you, and how to narrow a key safely.
:::

::: card Use with coding agents
[Point your agent at the contract](/agents-setup) — the OpenAPI document, the docs MCP endpoint, and the full-corpus file.
:::

:::
