# Agent DAGs

> Define and version the AI orchestration graphs that calls bind to.

An **agent DAG** is a directed-acyclic graph. It tells the gateway how
to drive an AI conversation: which model to call, which tools the model
can use, which fallback prompts to play, and which conditions start a
hand-off to a human. Trunks bind to a DAG by `agent_id`. Calls that
land on the trunk inherit the DAG.

> **WARNING:**
> There is no SDK wrapper. Use the [raw RPC envelope](/rpc/envelope-format) to call these methods.

## DAG shape

A DAG is a JSON document. On the wire, the gateway treats the body as
an opaque `string`. The server validates the body after decode.
The shape is:

```json
{
  "id": "support-bot-v3",
  "version": 7,
  "entry": "greet",
  "nodes": {
    "greet": {
      "type": "tts",
      "args": { "text": "Hello, how can I help?" },
      "next": "listen"
    },
    "listen": {
      "type": "stt",
      "args": { "model": "deepgram-nova-2" },
      "next": "decide"
    },
    "decide": {
      "type": "llm",
      "args": { "model": "gpt-4.1", "tools": ["lookup_order"] },
      "branches": {
        "tool:lookup_order": "do_lookup",
        "intent:human":       "transfer",
        "*":                  "listen"
      }
    },
    "do_lookup": { "type": "tool", "args": { "name": "lookup_order" }, "next": "decide" },
    "transfer":  { "type": "transfer", "args": { "trunk_id": "support-queue" } }
  }
}
```

The runtime recognises these `node_type` values today:

| Group           | Values                                          | Notes                                                                                |
|-----------------|-------------------------------------------------|--------------------------------------------------------------------------------------|
| Conversational  | `ASR`, `LLM`, `TTS`, `PUSH_AUDIO`, `REALTIME`   | Default cascaded pipeline is `ASR → LLM → TTS → PUSH_AUDIO`. `REALTIME` collapses ASR+LLM+TTS into one streaming provider session (OpenAI Realtime today). |
| IVR             | `GREETING`, `GATHER`, `MENU`                    | Branching nodes — `MENU` and `GATHER` set per-branch downstreams instead of a single `next_node`. |
| Control         | `TRANSFER`, `HANGUP`                            | Terminal control nodes; flow leaves the DAG after these.                             |

Node types are case-sensitive and uppercase. New types are additive.
Older runtimes ignore new types (forward-compat).

### Conversational nodes

| Field           | Type     | Notes                                                |
|-----------------|----------|------------------------------------------------------|
| `provider`      | `string` | Provider key. `ASR`: deepgram/whisper. `LLM`: openai/anthropic/gemini/ollama. `TTS`: deepgram/elevenlabs. `REALTIME`: openai. |
| `model`         | `string` | Freeform model identifier passed through to the provider (e.g. `claude-sonnet-4-6`, `gpt-4o-realtime-preview`). |
| `system_prompt` | `string` | LLM nodes only.                                      |
| `voice`         | `string` | TTS / REALTIME nodes.                                |
| `temperature`   | `number` | LLM / REALTIME.                                      |
| `language`      | `string` | ISO 639-1 hint. For REALTIME nodes, the runtime prepends a hard directive to the system prompt, so the model does not mirror the caller's language. |
| `next_node`     | `string` | Where flow goes after this node completes.           |

### IVR nodes

`GREETING` plays an opening message. `GATHER` waits for DTMF input.
`MENU` routes to one of N downstream nodes, one per digit (for example,
"press 1 for sales").

## Tools available to LLM nodes

An `llm` node's `args.tools` is a list of tool **names**. Names resolve
through the runtime's `ToolRegistry`. The LLM sees the tool's name,
description, and JSON schema. The runtime executes the tool and sends
the result back as a `tool_result`.

### Built-in implicit telephony toolset

Every session in FirstByte's agent runtime automatically
advertises these tools to the LLM:

| Name                | Args                                            | What it does                                                       |
|---------------------|-------------------------------------------------|--------------------------------------------------------------------|
| `transfer_call`     | `destination` (E.164 / SIP URI), `caller_id`?   | RFC 3515 SIP REFER. Per-trunk realm policy applies (see below). A denied transfer does not dispatch; it returns a structured `transfer_denied` envelope. |
| `hold_call`         | none                                            | sendrecv → sendonly re-INVITE.                                     |
| `unhold_call`       | none                                            | Lifts a previous `hold_call`. Idempotent on a non-held call.       |
| `disconnect_call`   | `reason`?                                       | Wire-level BYE. This is different from the model saying "goodbye". The `reason` value is written to the CDR. |
| `send_dtmf`         | `digit`, `duration_ms`? (40–2000, default 100), `mode`? (`rfc2833`\|`inband`, default `rfc2833`) | DTMF for IVR navigation post-transfer. |
| `request_supervisor`| `reason` (**required**), `urgency`? (`low`\|`medium`\|`high`, default `medium`) | Flags the call for human attention. It does not dispatch audio. Operators decide in the portal whether they join. To route the call, also use `transfer_call`. |
| `get_current_time`  | none                                            | ISO-8601 UTC timestamp.                                            |

To hide some of these tools for a tenant, write the tool names to
`clutchcall:tenant:<tenant_id>:telephony_disabled_tools` as a
JSON array. The runtime filters out disabled tools before the toolset
reaches the model. The runtime also checks the list again at invoke
time, so a stale provider session cannot bypass the gate.

**Realm policy on `transfer_call`.** Each trunk carries a list of
transfer rules. The rules are cached in
`clutchcall:trunk:<trunk_id>:transfer_rules`. The runtime walks
the rules on every transfer attempt. The first match wins.

When no rule matches:
- The runtime allows same-realm transfers (`reason="same_realm_default"`).
- The runtime **soft-allows cross-realm transfers with a warning**
  (`reason="cross_realm_default_warn"`). It logs the transfer and
  records it in the CDR, but the transfer proceeds. To enforce strict
  default-deny, write an explicit catch-all `deny` rule. The soft
  default exists so that operators who did not write rules yet can
  still make cross-realm transfers.

A rule that matches with `action: "deny"` returns a structured
envelope to the LLM:

```json
{
  "error": "transfer_denied",
  "reason": "cross-realm transfer requires explicit allow rule",
  "destination": "+15551234567",
  "source_realm": "internal",
  "dest_realm": "external",
  "matched_priority": -1
}
```

The model can then fall back, pick a different destination, or explain
the limitation to the caller. The downstream gateway never sees the
denied transfer.

### Operator-defined tools (HTTP / MCP / Client)

Operators add per-agent tools through the portal. They do not rebuild
the runtime. The runtime persists tools in Redis at
`agent:<agent_id>:config.tools[]` and hydrates them on the first audio
frame of each new session. These tools share the per-session
`ToolRegistry` with the implicit toolset above. When names collide, the
operator-defined tool wins. For example, a `transfer_call` declared on
the agent overrides the built-in `transfer_call`.

| Kind     | Status     | What it is |
| -------- | ---------- | ----------- |
| `http`   | shipped    | Calls an arbitrary HTTP(S) endpoint. URL, headers, and body templates support `{{argument}}` substitution from the LLM's tool arguments. Auth: `bearer`, `basic`, or custom header. 10s default timeout. |
| `mcp`    | shipped    | JSON-RPC 2.0 `tools/call` against a remote [MCP](https://modelcontextprotocol.io) server over HTTP. Each tool is declared explicitly; `tools/list` auto-discovery is not yet wired. Same auth shapes as `http`. |
| `client` | scaffolded | The runtime advertises the tool to the LLM and parses the call, but `invoke()` currently returns a `not_yet_plumbed` error. End-to-end dispatch to an SDK-side handler will land with the QaSupervisor → MoQ migration. |

Per-tool spec shape (one entry of `tools[]`):

```json
{
  "kind": "http",
  "name": "lookup_order",
  "description": "Look up an order by its ID.",
  "silent": false,
  "spec": {
    "method": "GET",
    "url": "https://api.example.com/orders/{{order_id}}",
    "headers": { "X-Tenant": "{{tenant_id}}" },
    "auth": { "type": "bearer", "token": "..." },
    "parameters": {
      "type": "object",
      "properties": {
        "order_id": { "type": "string", "description": "Order id to look up." }
      },
      "required": ["order_id"]
    },
    "timeout_ms": 10000
  }
}
```

> **WARNING:**
> HTTP and MCP tool invokes are synchronous within the LLM turn. A slow
> upstream uses the turn budget. Keep each tool fast (less than one
> second is ideal). As an alternative, chain multiple LLM turns instead
> of a cascade of tool calls inside one turn.

### Native tools (runtime build)

Some tools need privileged access to the runtime: realm policy
evaluation, telephony RPC dispatch, recording control, and supervisor
signalling. For these tools, subclass `host::core::Tool` and call
`register_tool()` in `main.cc`. This path is for platform developers.
Tenants and operators should use the `http` / `mcp` kinds above. See
the `clutchcall-tool-calling` skill for the C++ shape and the
`register_tool` call site.

### Client-side tool calling

When you bridge the call yourself (`default_app=ANSWER`), you handle
tool calling directly with the LLM provider's wire format. For OpenAI
Realtime, you send a `tools` array in `session.update`. The provider
sends `response.function_call_arguments.done` events back. The
`clutchcall-tool-calling` skill shows the full round-trip.

## `PublishAgentDag`

Request (`PublishAgentDagRequest`):

| Field         | Type     | Notes                                                |
| ------------- | -------- | ---------------------------------------------------- |
| `admin_token` | `string` |                                                      |
| `tenant_id`   | `string` |                                                      |
| `agent_id`    | `string` | Stable id; matches `AddTrunkRequest.agent_id`.       |
| `dag_json`    | `string` | The DAG document above. Validated server-side.       |
| `comment`     | `string` | Free-form changelog. It shows in `ListAgentDags`.    |

Response (`PublishAgentDagResponse`):

| Field             | Type     | Notes                                            |
| ----------------- | -------- | ------------------------------------------------ |
| `status`          | `string` | `"ok"` or `"error"`.                             |
| `error_message`   | `string` |                                                  |
| `version`         | `int32`  | Auto-incremented; `version=1` for the first publish. |
| `published_at_ms` | `int64`  |                                                  |

Each publish creates a new version. The gateway keeps the last 50
versions by default. New calls bind to the latest version at the moment
of `dial`.

## `GetAgentDag`

| Field         | Type     | Notes                                                |
| ------------- | -------- | ---------------------------------------------------- |
| `admin_token` | `string` |                                                      |
| `tenant_id`   | `string` |                                                      |
| `agent_id`    | `string` |                                                      |
| `version`     | `int32`  | `0` = latest. `>0` = specific historical version.    |

Response (`GetAgentDagResponse`):

| Field           | Type     | Notes                                            |
| --------------- | -------- | ------------------------------------------------ |
| `found`         | `bool`   |                                                  |
| `agent_id`      | `string` |                                                  |
| `version`       | `int32`  | The resolved version. This shows the actual version when you request `0`. |
| `dag_json`      | `string` | The DAG document.                                |
| `published_at_ms` | `int64` |                                                  |
| `comment`       | `string` |                                                  |

## `ListAgentDags`

| Field         | Type     |
| ------------- | -------- |
| `admin_token` | `string` |
| `tenant_id`   | `string` |

Response (`ListAgentDagsResponse`):

| Field    | Type                  | Notes                                |
| -------- | --------------------- | ------------------------------------ |
| `agents` | `vector<AgentSummary>` | Latest version per `agent_id`.      |

`AgentSummary`:

| Field             | Type     |
| ----------------- | -------- |
| `agent_id`        | `string` |
| `latest_version`  | `int32`  |
| `published_at_ms` | `int64`  |
| `comment`         | `string` |

## `DeleteAgentDag`

| Field         | Type     | Notes                                                  |
| ------------- | -------- | ------------------------------------------------------ |
| `admin_token` | `string` |                                                        |
| `tenant_id`   | `string` |                                                        |
| `agent_id`    | `string` |                                                        |
| `purge_history` | `bool` | `true` = also drop version history; `false` = keep it. |

Returns `Empty`. Trunks that point at the deleted `agent_id` will fail
inbound `HANDLE_AI` routing with `ERR_INVALID_DESTINATION`. This
continues until you rebind the trunks to another agent or republish
the DAG.
