> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swarmd.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend Integration (JSON-RPC — legacy)

> Build a frontend that invokes agents via channels or user sessions using the JSON-RPC a2a/0.3.0 protocol with tasks/get polling. Kept for compatibility with existing integrations.

<Info>
  **New TypeScript integrations should use [`@swarmd/channel-client`](/sdks/typescript/channel-client)**. It wraps the Conversation REST API, OAuth token refresh, polling, reply extraction, and typed lifecycle events. The JSON-RPC path documented here still works and remains supported; this guide is kept for existing integrations and protocol-level reference.
</Info>

# Frontend Integration (JSON-RPC — legacy)

This guide covers how to integrate your frontend application with Swarmd agents using the **JSON-RPC `a2a/0.3.0`** protocol. It applies to both **channel-based access** (for apps, bots, and services) and **user-based access** (for logged-in humans). Both access types use the same JSON-RPC protocol and follow the same request/response patterns — the only difference is the endpoint and how you authenticate.

***

## Access Types at a Glance

|                    | Channel Access                                                   | User Access                                       |
| ------------------ | ---------------------------------------------------------------- | ------------------------------------------------- |
| **Use case**       | Apps, bots, services, integrations                               | Dashboard, admin tools, manual testing            |
| **Auth method**    | OAuth2 client credentials (`clientId` / `clientSecret`)          | User login (`email` / `password`)                 |
| **Relay endpoint** | `POST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0` | `POST /relay/v1/human/agents/{agentId}/a2a/0.3.0` |
| **Token source**   | Keycloak token endpoint                                          | Swarmd login endpoint                             |
| **HITL policies**  | Tenant-level policies apply                                      | Tenant + user-level policies apply                |

***

## Authentication

### Channel Authentication

Channels authenticate using the **OAuth2 client credentials** flow. You received a `clientId` and `clientSecret` when the channel was created (see [Your First Agent](/tutorials/your-first-agent#option-a-create-a-channel-recommended-for-apps)).

```bash theme={null}
curl -s -X POST https://auth.swarmd.ai/realms/swarmd/protocol/openid-connect/token \
  -d "grant_type=client_credentials" \
  -d "client_id=channel-CHANNEL_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"
```

Response:

```json theme={null}
{
  "access_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 300
}
```

<Info>
  The `client_id` is always the channel's `clientId` field — formatted as `channel-{channelId}`. The token is short-lived (typically 5 minutes). Your app should refresh it before expiry by repeating the same request.
</Info>

### User Authentication

Users authenticate via the Swarmd login endpoint:

```bash theme={null}
curl -X POST https://api.swarmd.ai/tenant-auth/v1/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "your-password"
  }'
```

Response:

```json theme={null}
{
  "accessToken": "eyJhbG...",
  "tokenType": "Bearer",
  "expiresIn": 300,
  "refreshToken": "eyJhbG..."
}
```

Refresh before expiry:

```bash theme={null}
curl -X POST https://api.swarmd.ai/tenant-auth/v1/refresh \
  -H "Content-Type: application/json" \
  -d '{ "refreshToken": "eyJhbG..." }'
```

***

## Listing Available Agents

Before invoking an agent, your frontend may need to display a list of available agents.

### Channel: List Subscribed Agents

```bash theme={null}
curl https://api.swarmd.ai/registry/v1/channels/CHANNEL_ID/subscriptions \
  -H "Authorization: Bearer $SWARMD_TOKEN"
```

Response:

```json theme={null}
[
  {
    "subscriptionId": "...",
    "channelId": "d4ac7a03-...",
    "sinkAgentId": "98e0ee4b-...",
    "sinkAgentName": "time-agent",
    "createdAt": "2026-03-30T10:01:00Z"
  }
]
```

<Info>
  This endpoint requires a **user Bearer token** (from login), not a channel token. Channel management operations (creating, subscribing, listing) are admin actions performed by logged-in users.
</Info>

### User: List Subscriptions

```bash theme={null}
curl https://api.swarmd.ai/registry/v1/users/YOUR_USER_ID/subscriptions \
  -H "Authorization: Bearer $SWARMD_TOKEN"
```

***

## Sending a Request

Every interaction starts with a `message/send` call. The request body is identical for both access types — only the endpoint differs.

<Tabs>
  <Tab title="Channel">
    ```json theme={null}
    POST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0
    Authorization: Bearer {channel_access_token}

    {
      "jsonrpc": "2.0",
      "method": "message/send",
      "id": 1,
      "params": {
        "message": {
          "messageId": "a1b2c3d4-...",
          "role": "user",
          "parts": [
            { "kind": "text", "text": "What's the weather in London?" }
          ]
        }
      }
    }
    ```
  </Tab>

  <Tab title="User">
    ```json theme={null}
    POST /relay/v1/human/agents/{agentId}/a2a/0.3.0
    Authorization: Bearer {user_access_token}

    {
      "jsonrpc": "2.0",
      "method": "message/send",
      "id": 1,
      "params": {
        "message": {
          "messageId": "a1b2c3d4-...",
          "role": "user",
          "parts": [
            { "kind": "text", "text": "What's the weather in London?" }
          ]
        }
      }
    }
    ```
  </Tab>
</Tabs>

| Field                      | Required | Description                         |
| -------------------------- | -------- | ----------------------------------- |
| `method`                   | Yes      | Always `"message/send"`             |
| `jsonrpc`                  | Yes      | Always `"2.0"`                      |
| `id`                       | Yes      | Your request ID (integer or string) |
| `params.message.messageId` | Yes      | Unique UUID for this message        |
| `params.message.role`      | Yes      | Always `"user"`                     |
| `params.message.parts`     | Yes      | Array with at least one text part   |

***

## The Two Flows

There are two distinct scenarios depending on whether you call the agent directly or through a chain. These apply regardless of whether you use channel or user access.

### Flow 1: Direct Agent Call

You call an agent directly and that agent's response triggers HITL review.

```
Your App → Relay → Agent
                     ↓
               HITL triggered
                     ↓
           You poll until resolved
```

### Flow 2: Multi-Agent Chain

You call Agent A, which delegates to Agent B. Agent B's response triggers HITL review. Agent A waits for Agent B to resolve before responding to you.

```
Your App → Relay → Agent A → Relay → Agent B
                                        ↓
                                  HITL triggered
                                        ↓
                              Agent A waits (polling)
                                        ↓
                   Relay times out, returns "working" to you
                                        ↓
                           You poll until resolved
```

In both flows, your app follows the **same polling pattern**. The difference is what the final response contains.

***

## Every Response Your App Can Receive

### 1. `completed` — No Review Needed

The agent responded immediately with no HITL involved. Render the response and you're done.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "e6a262db-...",
    "contextId": "17aa30cf-...",
    "status": {
      "state": "completed",
      "message": {
        "role": "agent",
        "parts": [{ "kind": "text", "text": "The weather in New York is sunny." }]
      }
    },
    "kind": "task"
  }
}
```

**Action:** Render `result.status.message`. No polling needed.

**How to detect:** `result.status.state === "completed"`.

***

### 2. `working` — Relay-Managed Task, You Must Poll

The relay has taken ownership of this task and returned immediately so your request doesn't hang. The `metadata.relay_reason` field tells you **why**.

**How to detect:** `result.status.state === "working"` and `result.metadata.relay_reason` is present.

**Action (all cases):**

1. Save `result.id` — this is the task ID you'll poll with
2. Start polling `tasks/get` (see [How to Poll](#how-to-poll) below)
3. Show context-appropriate UI based on `relay_reason` (see below)

#### `TIMEOUT` — Agent is Slow

The agent didn't respond within the relay's early-return timeout. The relay is still waiting for the agent in the background.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "f7a3b2c1-...",
    "contextId": "17aa30cf-...",
    "status": { "state": "working" },
    "kind": "task",
    "metadata": {
      "relay_reason": "TIMEOUT"
    }
  }
}
```

**UI suggestion:** Show a "Processing..." or spinner. No human action is required — the agent is still working.

#### `HITL_HELD` — Policy Escalation

A detection policy (regex, Presidio, Comprehend) matched the message content and escalated it for human review. The metadata includes the triggering policy details so your UI can display context.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "f7a3b2c1-...",
    "contextId": "17aa30cf-...",
    "status": { "state": "working" },
    "kind": "task",
    "metadata": {
      "relay_reason": "HITL_HELD",
      "policy_name": "Large Transaction Policy",
      "policy_version": "2.0.0",
      "policy_level": "TENANT"
    }
  }
}
```

**UI suggestion:** Show "Pending review" with the policy name, e.g. *"Held by policy: Large Transaction Policy"*.

#### `HITL_HELD_AGENT_INPUT_REQUIRED` — Agent Requested Human Input

The downstream agent explicitly returned `INPUT_REQUIRED`, requesting human confirmation before proceeding. No policy was involved — the agent made this decision itself.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "f7a3b2c1-...",
    "contextId": "17aa30cf-...",
    "status": { "state": "working" },
    "kind": "task",
    "metadata": {
      "relay_reason": "HITL_HELD_AGENT_INPUT_REQUIRED"
    }
  }
}
```

**UI suggestion:** Show "Agent requires approval" — this is a confirmation step initiated by the agent, not a compliance check.

### 3. `canceled` — HITL Rejected

A human reviewer rejected the HITL request. If a policy triggered the original hold, the metadata includes the policy details.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "id": "f7a3b2c1-...",
    "contextId": "17aa30cf-...",
    "status": { "state": "canceled" },
    "kind": "task",
    "metadata": {
      "relay_reason": "HITL_REJECTED",
      "policy_name": "Large Transaction Policy",
      "policy_version": "2.0.0",
      "policy_level": "TENANT"
    }
  }
}
```

**Action:** Show "This request was not approved". No agent response is available.

<Info>
  The `HITL_REJECTED` reason can appear both in the initial `message/send` response (if the rejection happened before the relay returned) and in `tasks/get` poll responses. The `policy_name`, `policy_version`, and `policy_level` fields are nullable — they are `null` when the original hold was agent-initiated rather than policy-triggered.
</Info>

#### `relay_reason` Reference

| `relay_reason`                   | `status.state` | Meaning                                        | Policy fields included?                               |
| -------------------------------- | -------------- | ---------------------------------------------- | ----------------------------------------------------- |
| `TIMEOUT`                        | `working`      | Agent didn't respond within the timeout window | No                                                    |
| `HITL_HELD`                      | `working`      | Held for human review by a detection policy    | Yes (`policy_name`, `policy_version`, `policy_level`) |
| `HITL_HELD_AGENT_INPUT_REQUIRED` | `working`      | Agent explicitly requested human input         | No                                                    |
| `HITL_REJECTED`                  | `canceled`     | Human reviewer rejected the request            | Yes (nullable — present if policy-triggered)          |

#### `policy_level` Values

| Value          | Meaning                                     |
| -------------- | ------------------------------------------- |
| `TENANT`       | Policy is scoped to the entire tenant       |
| `AGENT`        | Policy is scoped to a specific agent        |
| `SUBSCRIPTION` | Policy is scoped to a specific subscription |

***

## How to Poll

Send a `tasks/get` request to the **same endpoint** you used for the original `message/send`:

<Tabs>
  <Tab title="Channel">
    ```json theme={null}
    POST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0
    Authorization: Bearer {channel_access_token}

    {
      "jsonrpc": "2.0",
      "method": "tasks/get",
      "id": 2,
      "params": {
        "id": "f7a3b2c1-..."
      }
    }
    ```
  </Tab>

  <Tab title="User">
    ```json theme={null}
    POST /relay/v1/human/agents/{agentId}/a2a/0.3.0
    Authorization: Bearer {user_access_token}

    {
      "jsonrpc": "2.0",
      "method": "tasks/get",
      "id": 2,
      "params": {
        "id": "f7a3b2c1-..."
      }
    }
    ```
  </Tab>
</Tabs>

| Field       | Description                                          |
| ----------- | ---------------------------------------------------- |
| `method`    | `"tasks/get"` (not `message/send`)                   |
| `params.id` | The `result.id` from the original `working` response |

Poll every **5 seconds** until you receive a terminal state.

***

## Poll Responses

### Still Pending

The review hasn't been resolved yet. Keep polling.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "f7a3b2c1-...",
    "contextId": "17aa30cf-...",
    "status": { "state": "working" }
  }
}
```

**Action:** Continue polling. Show "Pending review" in your UI.

***

### Approved — Direct Agent

An admin approved the request. The agent processed it and returned a result.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "090efcc0-...",
    "contextId": "20793fe2-...",
    "status": {
      "state": "completed",
      "timestamp": "2026-03-28T23:07:41.523665+00:00"
    },
    "artifacts": [
      {
        "artifactId": "6d374596-...",
        "parts": [
          { "kind": "text", "text": "The weather in London is rainy." }
        ]
      }
    ]
  }
}
```

**Action:** Stop polling. Render `result.artifacts[0].parts` or `result.status.message` as the agent's response.

***

### Approved — Multi-Agent Chain

An admin approved a downstream agent's HITL request. The parent agent received the downstream result, combined it with its own data, and returned a single complete answer.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "bfe9c650-...",
    "contextId": "64018875-...",
    "status": {
      "state": "completed",
      "message": {
        "role": "agent",
        "parts": [
          { "kind": "text", "text": "The time in London is 18:00, and the weather is rainy." }
        ]
      },
      "timestamp": "2026-03-28T20:28:58.595091+00:00"
    },
    "artifacts": [
      {
        "artifactId": "97c87236-...",
        "parts": [
          { "kind": "text", "text": "The time in London is 18:00, and the weather is rainy." }
        ]
      }
    ]
  }
}
```

**Action:** Stop polling. Render the response. This is the **complete, combined answer** from the entire chain. No partial answers to stitch together.

***

### Rejected — Direct Agent

An admin rejected the request. The task is canceled and the agent did not process it.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "090efcc0-...",
    "contextId": "20793fe2-...",
    "status": { "state": "canceled" }
  }
}
```

**Action:** Stop polling. Show a message like "This request was not approved" in your UI. There is no agent response to render.

***

### Rejected — Multi-Agent Chain

An admin rejected a downstream agent's HITL request. The parent agent continues **without** the downstream agent's data and responds with whatever information it had on its own.

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "d978cc99-...",
    "contextId": "80d34dcc-...",
    "status": {
      "state": "completed",
      "message": {
        "role": "agent",
        "parts": [
          { "kind": "text", "text": "The time in London is 18:00." }
        ]
      }
    },
    "artifacts": [
      {
        "artifactId": "ec988402-...",
        "parts": [
          { "kind": "text", "text": "The time in London is 18:00." }
        ]
      }
    ]
  }
}
```

**Action:** Stop polling. Render the response. Notice the weather data is **missing** because the downstream weather agent was rejected. The parent agent returned only what it could provide on its own.

<Warning>
  In a chain rejection, the response state is `completed` (not `canceled`) because the parent agent did complete — just without the blocked sub-agent's input. Your app should render this as a normal response. The user may not know data is missing unless the agent mentions it.
</Warning>

<Info>
  **No data is leaked when a downstream agent is rejected.** The rejected agent's response is never forwarded — the parent agent receives `null` for the delegation and has no access to the blocked agent's data. The parent can only respond with information it already had independently (in this example, the time). The HITL rejection acts as a hard gate: if it's rejected, that agent's data does not flow anywhere in the chain.
</Info>

***

## Terminal States Reference

Stop polling when you see any of these states:

| State       | Meaning                                        | What to render                                                   |
| ----------- | ---------------------------------------------- | ---------------------------------------------------------------- |
| `completed` | Agent finished (approved, or no HITL involved) | Render the agent's response from `status.message` or `artifacts` |
| `canceled`  | Admin rejected the HITL request                | Show "Request was not approved" — no agent response available    |
| `failed`    | An error occurred during processing            | Show an error message                                            |

***

## Decision Flowchart

```
Receive message/send response
         |
         v
Has metadata.relay_reason?
    YES -> Check relay_reason:
           |
           TIMEOUT -> Show "Processing..." (no human action needed)
           HITL_HELD -> Show "Pending review - policy: {policy_name}"
           HITL_HELD_AGENT_INPUT_REQUIRED -> Show "Agent requires approval"
           HITL_REJECTED -> Show "Request not approved"
           |
           If state is "working":
             Save result.id, poll tasks/get every 5s
             Stop on: completed, canceled, or failed
           If state is "canceled":
             Stop. Show rejection message.
     NO |
         v
Is state "completed"?
    YES -> Render response. Done.
```

***

## Polling Best Practices

| Setting               | Recommendation                                                 |
| --------------------- | -------------------------------------------------------------- |
| **Poll interval**     | 5 seconds                                                      |
| **Max poll duration** | Match your use case — HITL approvals can take minutes to hours |
| **Terminal states**   | Stop polling on `completed`, `failed`, or `canceled`           |
| **Task ID**           | Use `result.id` from the initial `working` response            |

The polling endpoint is the same as the endpoint you used for `message/send`:

| Access type | Endpoint                                                         |
| ----------- | ---------------------------------------------------------------- |
| **Channel** | `POST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0` |
| **User**    | `POST /relay/v1/human/agents/{agentId}/a2a/0.3.0`                |

***

## Key Fields Reference

| Field                          | Where               | Description                                                                                                                       |
| ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `result.id`                    | All responses       | Task ID. Use this to poll with `tasks/get`                                                                                        |
| `result.contextId`             | All responses       | Session ID. Use this for follow-up messages in the same conversation                                                              |
| `result.status.state`          | All responses       | Current task state: `working`, `completed`, `canceled`, `failed`                                                                  |
| `result.status.message`        | Completed responses | The agent's response message with `role` and `parts`                                                                              |
| `result.artifacts`             | Completed responses | The agent's output artifacts (same content as `status.message` in most cases)                                                     |
| `result.metadata.relay_reason` | Masked responses    | Discriminator indicating why the relay owns this task (`TIMEOUT`, `HITL_HELD`, `HITL_REJECTED`, `HITL_HELD_AGENT_INPUT_REQUIRED`) |

***

## Conversation Continuity

After a request resolves (whether HITL-blocked or not), use `contextId` from the response for follow-up messages. The agent retains the full conversation history.

<Tabs>
  <Tab title="Channel">
    ```json theme={null}
    POST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0

    {
      "jsonrpc": "2.0",
      "method": "message/send",
      "id": 3,
      "params": {
        "message": {
          "messageId": "b2c3d4e5-...",
          "contextId": "17aa30cf-...",
          "role": "user",
          "parts": [{ "kind": "text", "text": "What about tomorrow?" }]
        }
      }
    }
    ```
  </Tab>

  <Tab title="User">
    ```json theme={null}
    POST /relay/v1/human/agents/{agentId}/a2a/0.3.0

    {
      "jsonrpc": "2.0",
      "method": "message/send",
      "id": 3,
      "params": {
        "message": {
          "messageId": "b2c3d4e5-...",
          "contextId": "17aa30cf-...",
          "role": "user",
          "parts": [{ "kind": "text", "text": "What about tomorrow?" }]
        }
      }
    }
    ```
  </Tab>
</Tabs>

***

## Channel vs User: Differences for HITL

While the request/response format is identical, there are differences in how HITL policies are applied:

| Aspect              | Channel Access                                                                                      | User Access                                                  |
| ------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **Policy scoping**  | Tenant-level policies only                                                                          | Tenant + user-level policies                                 |
| **Skill filtering** | All agent skills visible (unfiltered)                                                               | Skills filtered per user's permissions                       |
| **HITL trigger**    | Agent-initiated `INPUT_REQUIRED` always applies; policy-based HITL applies if bound at tenant level | Both agent-initiated and user-scoped policy-based HITL apply |
| **Audit tracking**  | Records `channelId` and `channelSource`                                                             | Records `sourceUserId`                                       |

<Info>
  Both channel and user access support the full HITL workflow. The only difference is which policies are evaluated. If you've bound a HITL detection policy at the tenant level, it will apply to channel requests too. User-scoped policies (bound to specific users) only apply to user access.
</Info>

***

## Summary

| Scenario                       | `relay_reason`                                        | Initial State             | Polling? | Final State             | Response Contains                                    |
| ------------------------------ | ----------------------------------------------------- | ------------------------- | -------- | ----------------------- | ---------------------------------------------------- |
| Agent responds fast, no HITL   | *(none)*                                              | `completed`               | No       | `completed`             | Full agent response                                  |
| Agent is slow (timeout)        | `TIMEOUT`                                             | `working`                 | Yes      | `completed` or `failed` | Full agent response (when ready)                     |
| Policy escalation, approved    | `HITL_HELD`                                           | `working`                 | Yes      | `completed`             | Agent response after approval                        |
| Policy escalation, rejected    | `HITL_HELD` then `HITL_REJECTED`                      | `working` then `canceled` | Yes      | `canceled`              | No response (empty)                                  |
| Agent input required, approved | `HITL_HELD_AGENT_INPUT_REQUIRED`                      | `working`                 | Yes      | `completed`             | Agent response after approval                        |
| Agent input required, rejected | `HITL_HELD_AGENT_INPUT_REQUIRED` then `HITL_REJECTED` | `working` then `canceled` | Yes      | `canceled`              | No response (empty)                                  |
| Chain, downstream approved     | `HITL_HELD` or `TIMEOUT`                              | `working`                 | Yes      | `completed`             | Combined response from all agents                    |
| Chain, downstream rejected     | `HITL_HELD` or `TIMEOUT`                              | `working`                 | Yes      | `completed`             | Parent agent response only (missing downstream data) |

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Human-in-the-Loop Setup" icon="shield-check" href="/tutorials/human-in-the-loop">
    Configure HITL policies and manage approvals.
  </Card>

  <Card title="Policy Configuration" icon="sliders" href="/tutorials/policy-configuration">
    Set up detection policies that trigger HITL escalation.
  </Card>
</CardGroup>
