For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Call an agent over A2A
Use the A2A service that the kagent controller serves to read an Agent’s card, send it a message, and stream a reply.
Every AgentAgentA Kubernetes custom resource that pairs one AgentTemplate with one Harness. Each side takes either an inline spec or a reference to an existing resource, and the controller compiles the pair into a revision.Learn more is reachable over the A2AA2AThe Agent-to-Agent protocol, which callers and other agents use to talk to an Agent. The conversation's context identifier is the Session ID, so a second message on the same ID continues the same conversation.Learn more (Agent-to-Agent) protocol through the kagent controller. kagent uses the A2A protocol for its own agent traffic, rather than an extra interface beside it. The CLI, the MCP server, and any client you write all take the same path.
This example uses grpcurl to show the requests and replies directly. Real callers use an A2A client library rather than assembling requests by hand.
About the kagent A2A service
The controller serves lf.a2a.v1.A2AService on port 8083, alongside its REST API and its MCP endpoint. The kagent CLI reaches the same port and the same service.
A gRPC caller names the Agent in the standard A2A tenant field, as <namespace>/<name>, and the controller routes the call to that Agent. The same controller serves A2A over HTTP JSON-RPC, where the URL path selects the Agent instead:
| Transport | How a caller selects the Agent |
|---|---|
| gRPC | The tenant field on each request, as <namespace>/<name> |
| HTTP JSON-RPC | POST /agents/{namespace}/{name}, with the card at GET /agents/{namespace}/{name}/.well-known/agent-card.json |
An HTTP request that also sets a tenant must set one that matches its URL. There is no deployment-wide agent card, because the gateway serves many agents.
A caller addresses the Agent rather than one conversation. The message’s contextId selects the conversation. Omitting both a contextId and a task ID starts a new SessionSessionA running conversation with one Agent. Unlike the resources it is built from, a Session is not a Kubernetes resource: kagent's gRPC API creates it and its PostgreSQL database tracks it.Learn more, and repeating a contextId continues that Session.
Note
tenant and the /agents/{namespace}/{name} path replace the /api/a2a/<namespace>/<agent-name>/ URL paths that kagent 0.x served over HTTP.
Warning
The open source build does not authenticate this port. Any caller that can reach it can invoke any Agent, so do not expose port 8083 outside the cluster. For what the open source build does guarantee, see Identity.
A2A methods
This example calls each method in the following table except the three TaskPushNotificationConfig calls, which manage a task’s callbacks and which the kagent CLI wraps. The service defines more, including ListTasks. GetTaskPushNotificationConfig reads a single callback by ID.
| Method | What it does |
|---|---|
GetExtendedAgentCard | Returns the agent card describing the Agent. |
SendMessage | Sends a message and returns the task after the agent either finishes the turn or pauses for a person. |
SendStreamingMessage | Sends a message and streams events as the agent works. |
GetTask | Reads a task that a previous call created. |
CancelTask | Stops a task that is still running. |
CreateTaskPushNotificationConfig | Adds or replaces a callback for a task that is still active. |
ListTaskPushNotificationConfigs | Lists the active callbacks for one task. |
DeleteTaskPushNotificationConfig | Removes a task callback. |
Before you begin
Install kagent, and confirm that your installation sets
controller.grpc.reflection=true. Reflection lets grpcurl discover the controller’s methods without a local copy of kagent’s protocol buffer definitions.Create your first agent, then save the Agent’s namespace and name to an environment variable, in the
<namespace>/<name>form that the A2Atenantfield takes. To list your Agents, runkagent agent list.export AGENT=kagent/my-first-agentInstall grpcurl.
Port-forward the controller’s gRPC port, and leave the command running.
kubectl port-forward -n kagent svc/kagent-controller 8083:8083
Read the agent card
An A2A client typically starts by reading the agent card, which tells it what the agent is and which protocol features the agent supports.
Fetch the card for your Agent.
grpcurl -plaintext \ -d '{"tenant":"'"$AGENT"'"}' \ localhost:8083 lf.a2a.v1.A2AService/GetExtendedAgentCardExample output:
{ "name": "my_first_agent", "description": "My first kagent agent", "supportedInterfaces": [ { "url": "http://kagent-controller.kagent.svc:8083", "protocolBinding": "GRPC", "protocolVersion": "1.0" } ], "version": "v1", "capabilities": { "streaming": true, "pushNotifications": true, "extensions": [ { "uri": "https://kagent.dev/extensions/hitl/v1", "description": "Human in the loop for tool approval, ask user, and nested subagents" } ], "extendedAgentCard": true }, "defaultInputModes": ["text"], "defaultOutputModes": ["text"] }Read the card for what a caller acts on.
- Both
nameanddescriptioncome from the AgentTemplateAgentTemplateA Kubernetes custom resource defining what an agent does: its model, system prompt, tools, skills, and plugins. It runs only once an Agent pairs it with a Harness.Learn more.namereplaces hyphens with underscores, anddescriptionisspec.descriptionverbatim, so a caller sees the description you wrote. - The
supportedInterfacesURL is the controller’s in-cluster address rather than the Actor’s, because a caller reaches the agent through the controller. The card advertises JSON-RPC first, then gRPC. The gRPC interface carries thetenantvalue to send. - The
capabilities.pushNotificationsvalue advertises task callbacks. Each callback belongs to one task, not to the whole Session. - The
capabilities.extensionslist advertises human-in-the-loop support, which a client opts into per call.
- Both
Note
The card carries no skills. kagent 0.x let you declare agent card skills in an a2aConfig block. However, v1alpha3 has no such field, so kagent generates the card from the AgentTemplate’s name and description alone. An AgentTemplate’s spec.skills field is a different feature: those are Agent Skills that the agent can use, not advertisements to a caller.
Send a message
SendMessage blocks until the agent finishes the turn, or pauses to ask a person, and then returns the whole task. A message needs its own ID, a role, and at least one part.
A message part can also carry a file. Put the file’s bytes in the part’s raw field, which JSON encodes as base64, and set filename and mediaType on the same part.
Note
Unlike the UI, which checks each file as you stage it, this path accepts any media type. A file whose media type is not text-like reaches the model as the note [Uploaded file "report.pdf" (application/pdf) could not be read as text.] instead of contents. The controller’s gRPC server rejects any message over 16 MiB, framing included. For how files reach the model, and the file types and sizes that the UI accepts, see Attach files to a message.
Send a message to the Agent. Omitting a
contextIdstarts a new Session.grpcurl -plaintext \ -d '{"tenant":"'"$AGENT"'","message":{"messageId":"'"$(uuidgen)"'","role":"ROLE_USER","parts":[{"text":"What is 7 times 6? Answer with just the number."}]}}' \ localhost:8083 lf.a2a.v1.A2AService/SendMessageThe reply text arrives in
artifacts, not instatus. Example output, with the message history omitted:{ "task": { "id": "01a06cfb-a9ae-7ddb-be98-baaf17414998", "contextId": "01a068e3-aeb6-7abc-8d6f-5ba9becd3143", "status": { "state": "TASK_STATE_COMPLETED", "timestamp": "2026-09-04T15:13:52.990137169Z" }, "artifacts": [ { "artifactId": "01a06cfb-bf2c-70ea-8e65-e3ef15133a96", "parts": [{ "text": "42" }] } ], "history": [ ] } }The task returns two identifiers,
idandcontextId, and a caller uses them differently.- The
ididentifies one turn, and every message returns a new one. - The
contextIdidentifies the conversation, and is the Session’s own ID. A second message that repeats thiscontextIdtherefore continues the conversation rather than starting a new one.
Task metadata uses the
kagent.dev/a2a/namespace. An artifact carrieskagent.dev/a2a/timeline-position, an RFC 3339 timestamp that orders task history, and the task itself carrieskagent.dev/a2a/task-created-at. A runtime that reports model usage publishes it askagent.dev/a2a/usageon a task event rather than on the artifact.- The
Save the task’s
idand the conversation’scontextId, so that later steps can read the task and continue the same Session.export TASK_ID=<your-task-id> export SESSION_ID=<your-context-id>
Stream a reply
SendStreamingMessage takes the same request and returns a sequence of events instead of one result. A caller can then show a reply as the agent produces it.
Send a message on the streaming method.
grpcurl -plaintext \ -d '{"tenant":"'"$AGENT"'","message":{"messageId":"'"$(uuidgen)"'","contextId":"'"$SESSION_ID"'","role":"ROLE_USER","parts":[{"text":"Count from 1 to 3."}]}}' \ localhost:8083 lf.a2a.v1.A2AService/SendStreamingMessageRead the event sequence. The stream opens with the task at
TASK_STATE_SUBMITTED, moves toTASK_STATE_WORKING, and then emits an artifact update for each chunk of the reply. Every chunk shares oneartifactId, so a client appends them into a single artifact rather than treating each as a separate answer. Example output, abbreviated to the text of each event:"state": "TASK_STATE_SUBMITTED" "state": "TASK_STATE_WORKING" "artifactId": "01a06cfd-1c3c-7e65-8650-2e86f5d7f5eb", "text": "1" "artifactId": "01a06cfd-1c3c-7e65-8650-2e86f5d7f5eb", "text": "," "artifactId": "01a06cfd-1c3c-7e65-8650-2e86f5d7f5eb", "text": " " "artifactId": "01a06cfd-1c3c-7e65-8650-2e86f5d7f5eb", "text": "2"
Read a task later
A task outlives the call that created it, so a caller that lost its connection can read the result rather than asking the agent again.
Read the task by ID.
grpcurl -plaintext \ -d '{"tenant":"'"$AGENT"'","id":"'"$TASK_ID"'"}' \ localhost:8083 lf.a2a.v1.A2AService/GetTaskRead the task’s fields.
GetTaskreturns the task itself, rather than wrapping it in ataskfield the waySendMessagedoes. Thestatus,artifacts, andhistoryvalues are the ones that the original call returned.{ "id": "01a06cfb-a9ae-7ddb-be98-baaf17414998", "contextId": "01a068e3-aeb6-7abc-8d6f-5ba9becd3143", "status": { "state": "TASK_STATE_COMPLETED", "timestamp": "2026-09-04T15:13:52.990137169Z" }, "artifacts": [ { "artifactId": "01a06cfb-bf2c-70ea-8e65-e3ef15133a96", "parts": [{ "text": "42" }] } ], "history": [ ] }
A task that is still running reports TASK_STATE_WORKING and has no artifacts yet. To stop a task that is still running, call CancelTask with the same id.
Register task callbacks
A task callback lets a webhook learn when a task needs input or reaches a final state. The callback receives a status update for future INPUT_REQUIRED, AUTH_REQUIRED, COMPLETED, FAILED, CANCELED, and REJECTED states. After the callback arrives, the receiver calls authenticated GetTask to read the current state and artifacts.
The kagent CLI wraps the three push notification configuration methods, so the steps that follow use the CLI rather than building each request by hand. The CLI defaults to port 8083, which the prerequisites already forward.
To register a callback before a new task is dispatched, add
--push-urlto the kagent CLI invocation. The command returns immediately with the task ID and callback ID.kagent agent invoke \ --session $SESSION_ID \ --task "Run the long task." \ --push-url https://receiver.example/callback \ -o jsonSave the task ID from the command output so that later steps can address the task.
export PUSH_TASK_ID=<your-task-id>To add a second callback to a task that is still active, create one explicitly. The server rejects new callbacks after the task reaches a terminal state.
kagent agent session push create \ $SESSION_ID $PUSH_TASK_ID \ --id audit-webhook \ --url https://receiver.example/callbackList the callbacks for the task.
kagent agent session push list $SESSION_ID $PUSH_TASK_IDDelete a callback that should no longer receive task updates.
kagent agent session push delete $SESSION_ID $PUSH_TASK_ID audit-webhook
Secure callback deliveries
A callback URL is a public endpoint that receives task state from kagent. Before you register a callback in production, decide how the receiver establishes that a delivery came from kagent.
Important
kagent signs deliveries only when the installation names a signing key, and controller.push.signing.secretName is empty by default. Until you set it, kagent posts each callback without an Authorization header, and the controller serves 404 at /.well-known/jwks.json. A receiver cannot distinguish an unsigned kagent delivery from any other caller, so treat the default configuration as suitable for a trusted network only.
kagent does not generate its own signing key, so you are responsible for creating the key, keeping it, and rotating it. The following steps turn signing on.
Generate an Ed25519 private key in unencrypted PKCS#8 PEM form.
openssl genpkey -algorithm ed25519 -out private-key.pemNote
The
opensslbinary that ships with macOS is LibreSSL, which rejects this command withAlgorithm ed25519 not found. Install OpenSSL 3, or generate the key on another machine.Store the key in a Secret. kagent reads the
private-key.pementry, so the entry name is not optional.kubectl create secret generic kagent-push-signing -n kagent \ --from-file=private-key.pem=private-key.pemName the Secret in
controller.push.signing.secretName, and setcontroller.push.signing.issuerto a URL that reaches the controller. Use the Helm release name and version that you installed kagent with.helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \ --namespace kagent --reuse-values \ --set controller.push.signing.secretName=kagent-push-signing \ --set controller.push.signing.issuer=https://kagent.exampleThe issuer that a receiver trusts must match the host that it fetches the JWKS from. When
controller.push.signing.issueris empty, Helm falls back toui.externalUrland then to the in-cluster A2A gateway URL. Either fallback can name a host that does not serve the JWKS.Confirm that the controller publishes a verification key. Before you set the Secret, this path returns
404.curl -s http://localhost:8083/.well-known/jwks.jsonExample output, which carries the public key alone:
{"keys":[{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","use":"sig","kid":"B1BP8zPNOTEGYyBUVgUAeTyA5hPlnrNc0OB416NY-CM","x":"xIc8dv_I-NrYSvWKg4aKm_X1CjHmiUBSMf9cDXEKesM"}]}
Receivers verify a signed delivery against the JSON Web Key Set (JWKS). The controller serves the path on the same port as its gRPC, A2A, and MCP endpoints, so a receiver outside of the cluster needs a stable HTTPS URL that routes to the controller. The UI does not proxy the path, so publishing only the UI leaves a receiver with no key to verify against.
Receivers must not send a token or credential when they register a callback. kagent signs each delivery attempt with a fresh five-minute JWT whose audience is the exact callback URL, including the query string. Before you acknowledge the request with a 2xx response, verify the JWT signature, issuer, audience, expiry, and taskId claim. The taskId claim repeats the statusUpdate.taskId value in the body, so a receiver that compares them rejects a token replayed against a different task.
By default, callback URLs must use HTTPS. Callback URLs must not resolve to private, loopback, link-local, unspecified, or multicast addresses. To reach a test receiver inside the cluster, set controller.push.allowHTTP=true or controller.push.allowPrivateNetworks=true. kagent does not follow redirects, and failed attempts are retried from the database-backed queue.
When an agent needs a person
An agent can stop mid-task to ask a question or to request approval for a tool call. The task then reports TASK_STATE_INPUT_REQUIRED and waits until a caller answers it. Every kagent agent can raise the question kind, because the runtime gives each one a built-in ask_user tool, so a system prompt that tells an agent to ask before it answers is enough to see a pause.
Answering a pause needs the human-in-the-loop extension, which a caller requests per call. The extension is a versioned URI, and a request names it in two places: the A2A-Extensions header, and the message’s own extensions list.
Send a message that requests the extension.
grpcurl -plaintext \ -H "A2A-Extensions: https://kagent.dev/extensions/hitl/v1" \ -d '{"tenant":"'"$AGENT"'","message":{"messageId":"'"$(uuidgen)"'","role":"ROLE_USER","extensions":["https://kagent.dev/extensions/hitl/v1"],"parts":[{"text":"Should I increase the replica count?"}]}}' \ localhost:8083 lf.a2a.v1.A2AService/SendMessageRead the pause. The task stops at
TASK_STATE_INPUT_REQUIRED, and the status message’smetadataholds the request, keyed by the extension URI. Example output:{ "task": { "id": "01a0828d-6bc4-700a-b27e-9115b3174827", "status": { "state": "TASK_STATE_INPUT_REQUIRED", "message": { "parts": [{ "text": "Which environment do you mean for increasing the replica count?" }], "metadata": { "https://kagent.dev/extensions/hitl/v1": { "type": "ask_user_request", "id": "adk-823f48ab-4a0b-4652-a23f-e9db9724d35f", "questions": [ { "question": "Which environment do you mean for increasing the replica count?", "choices": ["development", "staging", "production"], "multiple": false } ] } }, "extensions": ["https://kagent.dev/extensions/hitl/v1"] } } } }Save the task ID, the conversation’s
contextId, and the requestidto environment variables. The requestidis within the extension metadata and starts withadk-.export PAUSED_TASK_ID=<your-task-id> export PAUSED_SESSION_ID=<your-context-id> export REQUEST_ID=<your-request-id>Answer on the same task. The response goes in the same metadata key, names the request
idit answers, and setstaskIdso that it answers the paused task rather than starting a new turn.grpcurl -plaintext \ -H "A2A-Extensions: https://kagent.dev/extensions/hitl/v1" \ -d '{"tenant":"'"$AGENT"'","message":{"messageId":"'"$(uuidgen)"'","taskId":"'"$PAUSED_TASK_ID"'","contextId":"'"$PAUSED_SESSION_ID"'","role":"ROLE_USER","extensions":["https://kagent.dev/extensions/hitl/v1"],"parts":[{"text":"staging"}],"metadata":{"https://kagent.dev/extensions/hitl/v1":{"type":"ask_user_response","id":"'"$REQUEST_ID"'","answers":[{"answer":["staging"]}]}}}}' \ localhost:8083 lf.a2a.v1.A2AService/SendMessageThe agent resumes where it paused and finishes the turn. Example output:
"state": "TASK_STATE_COMPLETED" "text": "For the staging environment, I recommend increasing the replica count to ensure better load distribution and fault tolerance during testing."
Important
A caller that does not request the extension is still interrupted. The task stops at TASK_STATE_INPUT_REQUIRED exactly as before, and the question arrives as ordinary text on the status message, but the metadata carries no request id. Without that id there is nothing to answer, so the task waits until something cancels it. Request the extension on any call to an agent that can pause.
A tool approval works the same way with a different payload, tool_approval_request answered by tool_approval_response. For the pause kinds, the approval model, and what a nested agent’s pause looks like, see Human in the loop.
Clean up
- This example creates no Kubernetes resources, so you have nothing to delete.
- You can stop the 8083 port-forward for the kagent-controller service with
Ctrl+C. - The tasks that your messages created stay on the Session as part of its conversation, and deleting the Session removes them.