Connecting Albato Copilot to Your Agent over A2A
IN THIS ARTICLE
A guide for developers at Albato embedded partners. It explains how your AI agent can delegate work to Albato Copilot over the open A2A protocol (Agent2Agent, version 1.0) to have it build an automation and show the result to the user inside your own product.
What Copilot Is over A2A
Albato Copilot is an agent that, on behalf of an Albato user:
- builds, configures, validates and starts automations (bundles): picks apps, triggers and actions, connections, maps fields;
- searches the Albato catalog for apps, their triggers and actions, and lists the user's connections;
- inspects existing bundles and explains why they are not running.
Over A2A your agent sends Copilot a task as plain text in the user's language and receives a stream of events back: what Copilot is doing right now, the questions it has, and the final answer. Technically it is JSON-RPC 2.0 over HTTPS with Server-Sent Events (SSE) for streaming. The official specification and SDKs live at a2a-protocol.org; the Python package is a2a-sdk.
Key concepts:
| Term | Meaning |
|---|---|
| Agent Card | JSON description of the agent at <COPILOT_URL>/.well-known/agent-card.json: skills, JSON-RPC address, auth requirements. The SDK reads it for you |
| Task | One unit of work (one turn of the conversation). Has a taskId and a state: SUBMITTED → WORKING → COMPLETED, FAILED, CANCELED, or INPUT_REQUIRED (Copilot is waiting for an answer) |
| contextId | Conversation identifier. Send it with every following message and Copilot remembers what has already been done |
| Artifact | The task result. For Copilot it is the answer artifact: the answer text and, if a bundle was created, its bundle_id |
What Your Albato Manager Provides
- The Copilot address, a base URL, referred to below as <COPILOT_URL>.
- A partner token that looks like cpa2a_…, a secret that identifies your agent. One per partner. Keep it on your server like any API key and never send it to a browser. If it is exposed, contact your manager: it will be reissued and the old one stops working immediately.
Authentication: Two Tokens on Every Request
Every request to <COPILOT_URL>/a2a must carry two headers:
| Header | What it is | Where it comes from |
|---|---|---|
| X-Copilot-Partner-Token: cpa2a_… | Who is asking: your agent | Issued by your Albato manager |
| Authorization: Bearer <JWT> | On whose behalf: the Albato user Copilot works for | That user's Albato session token (see section 4) |
Plus the protocol header A2A-Version: 1.0 (SDK clients add it automatically; add it yourself for manually configured requests).
Optional: if the user works inside an Albato Teams workspace, add the WorkspaceId and SpaceId headers, the same ones your UI sends to the Albato API.
The partner token never substitutes for the user token: without the user's token Copilot cannot do anything in their account. There is no anonymous mode.
Getting the User Token and Passing It to Copilot
Copilot accepts the same Albato session token you already use to show the Albato UI inside your product: the session_token passed to the Albato UI when it opens (stored in the browser in the authToken_<env> cookie). No separate Copilot token is needed.
Recommended layout:
user's browser your backend (the agent lives here) Albato Copilot
user types in chat → your API (your own authentication)
looks up this user's Albato session token, the one
you received from Albato when you opened the
Albato UI for them
→ POST <COPILOT_URL>/a2a
X-Copilot-Partner-Token: cpa2a_… (server secret)
Authorization: Bearer <user's session_token>
A2A-Version: 1.0 → Copilot
Copilot answers/questions ← your API streams them to your UI ← event stream
Rules:
- Run the A2A client on your server. The partner token must not reach the browser. If your agent runs in the frontend, put your own proxy between it and Copilot that adds the partner token.
- Your backend holds the user token, not the page. Your frontend authenticates against your API as usual and the backend attaches the Albato token itself, so it never ends up in JavaScript or browser logs. If your architecture does pass it from the frontend, send it only in the body or a header of an HTTPS request to your own API, never in a URL.
- Lifetime equals the user's Albato session. If Copilot answers 401 for the user token, obtain a fresh one the same way you do for the Albato UI and retry.
- One user token, one user. Never send one user's tasks with another user's token: Copilot would act in the wrong account.
Step by Step
Step 1. Read the Agent Card
curl -s <COPILOT_URL>/.well-known/agent-card.json
The card is public. You need the JSON-RPC address (supportedInterfaces[0].url, which is <COPILOT_URL>/a2a) and the list of skills. The SDK does this for you.
Step 2. Send a Task
Use the streaming method SendStreamingMessage: you see progress immediately and the connection does not hit load balancer timeouts. The blocking SendMessage keeps the HTTP connection open until the task ends (which can take minutes) and is only suitable for short requests.
curl -N -s -X POST <COPILOT_URL>/a2a \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "A2A-Version: 1.0" \
-H "X-Copilot-Partner-Token: $COPILOT_PARTNER_TOKEN" \
-H "Authorization: Bearer $ALBATO_USER_TOKEN" \
-d '{
"jsonrpc": "2.0", "id": "1", "method": "SendStreamingMessage",
"params": {"message": {"messageId": "m-1", "role": "ROLE_USER",
"parts": [{"text": "Which triggers does Telegram have?"}]}}
}'
messageId is any unique identifier on your side (a UUID, for example).
Step 3. Read the Event Stream
Each SSE event is a data: {...} line with a JSON-RPC response whose result is one of:
| Event | What to do |
|---|---|
| task | Task started. Store task.id (taskId) and task.contextId |
| statusUpdate with state: TASK_STATE_WORKING | Progress. status.message.parts[0].text holds a short line ("Calling search_partners", "step_2: completed …"), the second part is a DataPart with details. Use this for a progress indicator |
| artifactUpdate | The final answer: artifact.name == "answer", text in parts[0].text, a DataPart may carry the bundle_id of the created bundle |
| statusUpdate with state: TASK_STATE_COMPLETED | Done. The answer text is repeated in status.message |
| statusUpdate with state: TASK_STATE_INPUT_REQUIRED | Copilot is waiting for the user, see step 4 |
| statusUpdate with state: TASK_STATE_FAILED | Error: text in status.message, DataPart with error_code and recoverable |
| statusUpdate with state: TASK_STATE_CANCELED | The task was canceled |
The stream ends when the task finishes or reaches INPUT_REQUIRED.
Step 4. Copilot's Questions (INPUT_REQUIRED)
Copilot often asks for clarification: which action to use, which connection, what exactly to build. The task then moves to TASK_STATE_INPUT_REQUIRED and status.message contains:
- the question text, parts[0].text;
- a DataPart with details, parts[1].data:
- {"type": "question"}, a free-form question, answer with text;
- {"type": "hitl_request", "hitl_type": "select" | "add_connection" | "connection_actions" | …, "prompt": "…", "options": [{"option_id": "…", "label": "…"}], "connection_url": "…"?}, a choice between options.
Show the question and options to the user and send their answer as a new message with the same taskId and contextId:
{"jsonrpc": "2.0", "id": "2", "method": "SendStreamingMessage",
"params": {"message": {"messageId": "m-2", "role": "ROLE_USER",
"taskId": "<taskId>", "contextId": "<contextId>",
"parts": [{"text": "My Telegram bot"}]}}}
For options you may send the option_id, its label (case-insensitive), or a separate part {"data": {"selected_option_id": "cred_1"}}. If the answer matches no option, the task stays in INPUT_REQUIRED and the message repeats the list of options.
A special case is add_connection: the user has to connect an app. The DataPart carries a connection_url. Open it for the user in a browser (new window), wait until they authorize, then reply with the add_connection option. Copilot then offers connection_actions with continue and try_again. This step cannot be automated: a human performs it.
Step 5. Continue the Conversation
- Next message in the same conversation: pass the contextId from the first reply (without taskId). Copilot keeps the context: apps found, the bundle being built, earlier answers.
- A new message without taskId while the previous task is unfinished: the running task is interrupted, an unanswered question is canceled, and a new task starts.
- Cancel a task: {"method": "CancelTask", "params": {"id": "<taskId>"}}.
- Check a task: {"method": "GetTask", "params": {"id": "<taskId>"}}.
- A message to a finished task is rejected, start a new one with the same contextId.
Python Example (a2a-sdk)
pip install "a2a-sdk>=1.1" httpx
import asyncio
import os
import httpx
from a2a.client import ClientConfig, create_client
from a2a.helpers import (
get_data_parts, get_stream_response_text,
new_data_part, new_message, new_text_part,
)
from a2a.types import Role, SendMessageRequest, TaskState
COPILOT_URL = os.environ["COPILOT_URL"] # from your manager
PARTNER_TOKEN = os.environ["COPILOT_PARTNER_TOKEN"] # server secret
async def ask_copilot(user_token, text, *, context_id=None, task_id=None,
selected_option_id=None):
http = httpx.AsyncClient(
headers={
"X-Copilot-Partner-Token": PARTNER_TOKEN,
"Authorization": f"Bearer {user_token}",
},
timeout=httpx.Timeout(600, connect=10),
)
config = ClientConfig(streaming=True, httpx_client=http)
client = await create_client(COPILOT_URL, client_config=config)
parts = [new_text_part(text)] if text else []
if selected_option_id:
parts.append(new_data_part({"selected_option_id": selected_option_id}))
message = new_message(parts, context_id=context_id, task_id=task_id,
role=Role.ROLE_USER)
state, answer, question = None, "", None
async for chunk in client.send_message(SendMessageRequest(message=message)):
if chunk.HasField("task"):
task_id, context_id = chunk.task.id, chunk.task.context_id
elif chunk.HasField("status_update"):
status = chunk.status_update.status
state = TaskState.Name(status.state)
if state == "TASK_STATE_WORKING":
progress = get_stream_response_text(chunk)
if progress:
print("[copilot]", progress) # progress
elif state == "TASK_STATE_INPUT_REQUIRED":
datas = get_data_parts(status.message.parts)
question = {
"text": get_stream_response_text(chunk),
"data": datas[0] if datas else None,
}
elif chunk.HasField("artifact_update"):
answer += get_stream_response_text(chunk)
await client.close()
await http.aclose()
return {"state": state, "answer": answer, "question": question,
"task_id": task_id, "context_id": context_id}
async def main():
user_token = "<the Albato user's session_token>"
r = await ask_copilot(user_token, "Create an automation: new lead in amoCRM "
"-> message in Telegram")
while r["state"] == "TASK_STATE_INPUT_REQUIRED":
print("Copilot asks:", r["question"]["text"])
for opt in (r["question"]["data"] or {}).get("options", []):
print(" -", opt["option_id"], ":", opt["label"])
reply = input("User's answer: ")
r = await ask_copilot(user_token, reply,
context_id=r["context_id"], task_id=r["task_id"])
print(r["state"], r["answer"])
asyncio.run(main())
Use one httpx.AsyncClient per user conversation: it keeps cookies and the connection, which makes requests faster and lets the load balancer keep the conversation on one server.
Errors
HTTP errors arrive before JSON-RPC:
| Code | Cause | What to do |
|---|---|---|
| 401 | Missing partner token, or missing/expired user token (detail says which) | Check the headers; refresh the user token |
| 403 | Partner token unknown or revoked | Contact your manager |
JSON-RPC errors arrive with HTTP 200 in the error field:
| Code | Meaning |
|---|---|
| -32602 | Invalid params: empty message, foreign or non-UUID contextId, contextId not matching the task, message to a finished task |
| -32001 | Task not found (unknown or stale taskId), start a new task with the same contextId |
| -32009 | Missing A2A-Version: 1.0 header |
| -32603 | Internal Copilot error, retry later |
Copilot's own failures (no access to the Albato API, account limit reached, agent error) arrive as TASK_STATE_FAILED with a user-facing text.
Limits and Tips
- One task may run for up to 15 minutes; after that it is interrupted and returned as FAILED.
- Tasks within one conversation (contextId) are sequential: wait for completion or INPUT_REQUIRED before sending the next message.
- Write to Copilot in the user's language, the answer comes back in the same language.
- Chats of A2A sessions will be available in the Albato partner dashboard.
- Do not log tokens. Keep the partner token on the server and the user token only in request memory.
For help, contact your Albato manager or the Albato support team.
Did this answer your question?