Skip to main content

Frontend Integration

This guide covers how to integrate your frontend application with Swarmd agents. 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 AccessUser Access
Use caseApps, bots, services, integrationsDashboard, admin tools, manual testing
Auth methodOAuth2 client credentials (clientId / clientSecret)User login (email / password)
Relay endpointPOST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0POST /relay/v1/human/agents/{agentId}/a2a/0.3.0
Token sourceKeycloak token endpointSwarmd login endpoint
HITL policiesTenant-level policies applyTenant + 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).
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:
{
  "access_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 300
}
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.

User Authentication

Users authenticate via the Swarmd login endpoint:
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:
{
  "accessToken": "eyJhbG...",
  "tokenType": "Bearer",
  "expiresIn": 300,
  "refreshToken": "eyJhbG..."
}
Refresh before expiry:
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

curl https://api.swarmd.ai/registry/v1/channels/CHANNEL_ID/subscriptions \
  -H "Authorization: Bearer $SWARMD_TOKEN"
Response:
[
  {
    "subscriptionId": "...",
    "channelId": "d4ac7a03-...",
    "sinkAgentId": "98e0ee4b-...",
    "sinkAgentName": "time-agent",
    "createdAt": "2026-03-30T10:01:00Z"
  }
]
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.

User: List Subscriptions

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.
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?" }
      ]
    }
  }
}
FieldRequiredDescription
methodYesAlways "message/send"
jsonrpcYesAlways "2.0"
idYesYour request ID (integer or string)
params.message.messageIdYesUnique UUID for this message
params.message.roleYesAlways "user"
params.message.partsYesArray 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.
{
  "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 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.
{
  "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.
{
  "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.
{
  "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.
{
  "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.
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.

relay_reason Reference

relay_reasonstatus.stateMeaningPolicy fields included?
TIMEOUTworkingAgent didn’t respond within the timeout windowNo
HITL_HELDworkingHeld for human review by a detection policyYes (policy_name, policy_version, policy_level)
HITL_HELD_AGENT_INPUT_REQUIREDworkingAgent explicitly requested human inputNo
HITL_REJECTEDcanceledHuman reviewer rejected the requestYes (nullable — present if policy-triggered)

policy_level Values

ValueMeaning
TENANTPolicy is scoped to the entire tenant
AGENTPolicy is scoped to a specific agent
SUBSCRIPTIONPolicy 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:
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-..."
  }
}
FieldDescription
method"tasks/get" (not message/send)
params.idThe 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.
{
  "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.
{
  "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.
{
  "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.
{
  "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.
{
  "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.
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.
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.

Terminal States Reference

Stop polling when you see any of these states:
StateMeaningWhat to render
completedAgent finished (approved, or no HITL involved)Render the agent’s response from status.message or artifacts
canceledAdmin rejected the HITL requestShow “Request was not approved” — no agent response available
failedAn error occurred during processingShow 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

SettingRecommendation
Poll interval5 seconds
Max poll durationMatch your use case — HITL approvals can take minutes to hours
Terminal statesStop polling on completed, failed, or canceled
Task IDUse result.id from the initial working response
The polling endpoint is the same as the endpoint you used for message/send:
Access typeEndpoint
ChannelPOST /relay/v1/channels/{channelId}/agents/{agentId}/a2a/0.3.0
UserPOST /relay/v1/human/agents/{agentId}/a2a/0.3.0

Key Fields Reference

FieldWhereDescription
result.idAll responsesTask ID. Use this to poll with tasks/get
result.contextIdAll responsesSession ID. Use this for follow-up messages in the same conversation
result.status.stateAll responsesCurrent task state: working, completed, canceled, failed
result.status.messageCompleted responsesThe agent’s response message with role and parts
result.artifactsCompleted responsesThe agent’s output artifacts (same content as status.message in most cases)
result.metadata.relay_reasonMasked responsesDiscriminator 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.
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?" }]
    }
  }
}

Channel vs User: Differences for HITL

While the request/response format is identical, there are differences in how HITL policies are applied:
AspectChannel AccessUser Access
Policy scopingTenant-level policies onlyTenant + user-level policies
Skill filteringAll agent skills visible (unfiltered)Skills filtered per user’s permissions
HITL triggerAgent-initiated INPUT_REQUIRED always applies; policy-based HITL applies if bound at tenant levelBoth agent-initiated and user-scoped policy-based HITL apply
Audit trackingRecords channelId and channelSourceRecords sourceUserId
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.

Summary

Scenariorelay_reasonInitial StatePolling?Final StateResponse Contains
Agent responds fast, no HITL(none)completedNocompletedFull agent response
Agent is slow (timeout)TIMEOUTworkingYescompleted or failedFull agent response (when ready)
Policy escalation, approvedHITL_HELDworkingYescompletedAgent response after approval
Policy escalation, rejectedHITL_HELD then HITL_REJECTEDworking then canceledYescanceledNo response (empty)
Agent input required, approvedHITL_HELD_AGENT_INPUT_REQUIREDworkingYescompletedAgent response after approval
Agent input required, rejectedHITL_HELD_AGENT_INPUT_REQUIRED then HITL_REJECTEDworking then canceledYescanceledNo response (empty)
Chain, downstream approvedHITL_HELD or TIMEOUTworkingYescompletedCombined response from all agents
Chain, downstream rejectedHITL_HELD or TIMEOUTworkingYescompletedParent agent response only (missing downstream data)

Next Steps

Human-in-the-Loop Setup

Configure HITL policies and manage approvals.

Policy Configuration

Set up detection policies that trigger HITL escalation.