A2A with Governance Extension
Turn-based execution protocol with governance layer
External Agent Contract — AG-UI + A2A Dual-Protocol for External Agents
Note: This document describes the turn-based contract between external agents and the platform. Agents built with the Python or TypeScript SDK implement this contract automatically — the SDK handles AG-UI SSE streaming, A2A v1.0 JSON-RPC, tool result extraction, and state management. If you're using the SDK, see SDK_GUIDE.md instead. This document is for understanding the low-level protocol or building agents without the SDK.
Overview
The dual-protocol model is a framework-neutral approach that lets external agents (LangGraph, CrewAI, PydanticAI, AutoGen, or any language/framework) run on the platform with the same observability, HITL gates, and durability as platform-managed agents.
Core principle: Agent declares, platform executes.
Your agent returns toolCalls[] declarations. The platform executes each tool as a separate Temporal activity — with D2 grant enforcement, HITL approval, retry, audit trail, and per-tool visibility in the Temporal UI.
Two Integration Paths
| Path | Endpoint | Transport | When to Use |
|---|---|---|---|
| SDK-based | POST /ag-ui (SSE) + POST / (JSON-RPC) | AG-UI + A2A v1.0 | Use the Python/TypeScript SDK — handles everything |
| Raw turn-based | POST /turn (JSON) | HTTP JSON | Build your own agent without the SDK |
Both paths produce the same Temporal activity structure. The SDK path adds automatic A2UI surface generation, STATE_SNAPSHOT emission, and A2A agent card publishing.
Why This Matters
Without the protocol integration, external agents run as a single opaque Temporal activity. The entire pipeline (all LLM calls, tool calls, and steps) is one blob. Temporal sees:
externalAgentWorkflow
Activity 1: publishWorkflowEvent
Activity 2: executeExternalAgentStep (entire pipeline — 90s, one blob)
Activity 3: finalizeExternalWorkflow
With the dual-protocol model, Temporal sees every tool call:
externalAgentWorkflow
Activity 1: publishWorkflowEvent (workflow_started)
Activity 2: runAgentTurn (turn 0: analyze topic, request web_search)
Activity 3: executeToolStep (web_search: "quantum computing basics")
Activity 4: runAgentTurn (turn 1: summarize, request next search)
Activity 5: executeToolStep (web_search: "quantum computing applications")
Activity 6: runAgentTurn (turn 2: final report, nextAction: done)
Activity 7: executeExternalGuardrailStep
Activity 8: finalizeExternalWorkflow
This gives you:
- Per-tool retry with backoff
- Per-tool HITL approval gates
- Per-tool timeout and visibility
- Workflow restart from the last successful activity (not from scratch)
- Full audit trail of every tool execution
Protocol
Endpoint
Your agent exposes a POST /turn endpoint that accepts AgentTurnRequest and returns AgentTurnResponse.
Request → Response Flow
Platform Your Agent
| |
| POST /turn |
| { threadId, messages, |
| context: {}, turnNumber: 0 } |
|--------------------------------->|
| | (LLM reasoning)
| { messages: [...], |
| toolCalls: [{web_search}], |
| nextAction: "await_tool_results",
| statePatch: {step: "..."} } |
|<---------------------------------|
| |
| (Platform executes web_search |
| as Temporal activity) |
| |
| POST /turn |
| { threadId, messages, |
| toolResults: [{...}], |
| context: {step: "..."}, |
| turnNumber: 1 } |
|--------------------------------->|
| | (Process results)
| { messages: [{answer}], |
| nextAction: "done" } |
|<---------------------------------|
AgentTurnRequest
Sent TO your agent at the start of each turn.
{
"threadId": "conv-abc123",
"messages": [
{ "role": "user", "content": "Research quantum computing" },
{ "role": "assistant", "content": "Let me search..." },
{ "role": "tool", "content": "{...results...}", "toolCallId": "tc-1" }
],
"toolResults": [
{
"toolCallId": "tc-1",
"status": "success",
"output": { "results": [...] }
}
],
"context": { "step": "researching", "questions": ["..."] },
"turnNumber": 1
}
| Field | Type | Description |
|---|---|---|
threadId | string | Conversation ID (same across all turns) |
messages | AgentMessage[] | Full message history including tool results |
toolResults | ToolResult[]? | Results from tool calls your agent requested last turn. Empty/undefined on turn 0. |
context | object | Your agent's statePatch from the previous turn. {} on turn 0. The platform stores this but never reads it. |
turnNumber | number | 0-indexed turn counter |
availableTools | AvailableTool[]? | Tools granted to this agent (turn 0 only). See Tool Discovery. |
AgentTurnResponse
Returned FROM your agent after processing a turn.
{
"messages": [
{ "role": "assistant", "content": "Let me search for that..." }
],
"toolCalls": [
{
"id": "tc-1",
"name": "web_search",
"arguments": { "query": "quantum computing", "limit": 5 }
}
],
"nextAction": "await_tool_results",
"statePatch": { "step": "researching", "questions": ["..."] },
"tokenUsage": { "input": 1500, "output": 200, "cost": 0.01 }
}
| Field | Type | Description |
|---|---|---|
messages | AgentMessage[] | New messages from this turn (shown to user) |
toolCalls | AgentToolCall[]? | Tools you want the platform to execute. Required when nextAction is await_tool_results. |
nextAction | string | What happens next (see below) |
statePatch | object? | Opaque state for next turn. Must be JSON-serializable. |
tokenUsage | object? | Token usage for cost attribution |
nextAction Values
| Value | Meaning |
|---|---|
"done" | Agent is finished. messages contains the final response. |
"await_tool_results" | Agent wants tools executed. toolCalls lists the tools. Platform executes them and sends results on next turn. |
"continue" | Agent wants another turn without tool calls (e.g., multi-step reasoning). |
"await_approval" | Agent wants human approval before continuing. |
Tool Call Declaration
Your agent declares WHAT it wants to call. The platform EXECUTES it.
{
"id": "tc-1",
"name": "web_search",
"arguments": { "query": "quantum computing", "limit": 5 },
"idempotencyKey": "search-quantum-v1"
}
| Field | Type | Description |
|---|---|---|
id | string | Unique ID (agent-generated). Used to match results back. |
name | string | Tool name. Must match a tool registered on the platform. |
arguments | object | Arguments to pass to the tool. |
idempotencyKey | string? | Optional. If provided, the platform returns cached results for the same key on Temporal retry. Recommended for non-idempotent tools. |
Tool Result
Sent back to your agent on the next turn.
{
"toolCallId": "tc-1",
"status": "success",
"output": { "results": [...] }
}
| Status | Meaning |
|---|---|
"success" | Tool executed successfully. output has the result. |
"error" | Tool failed. error has the message. |
"rejected" | Tool was blocked by HITL review. rejectionReason explains why. |
statePatch — Agent State Management
The statePatch field lets your agent maintain state between turns without the platform ever interpreting it. Use it for:
- Pipeline position (which step/question you're on)
- Accumulated results from previous turns
- Graph state (for LangGraph agents)
- Any JSON-serializable data your agent needs
The platform stores statePatch in Temporal workflow state and passes it back as context on the next turn. On turn 0, context is {}.
# Turn 0: Initialize state
return {
"statePatch": {
"questions": ["Q1", "Q2", "Q3"],
"current_index": 0,
"findings": [],
}
}
# Turn 1: context contains the statePatch from turn 0
def handle_turn(request):
questions = request.context["questions"]
current_idx = request.context["current_index"]
# ... process and return updated statePatch
Platform Configuration
To enable the AG-UI + A2A protocols for an agent, set these fields in externalConfig:
{
"externalConfig": {
"endpointUrl": "http://your-agent:8200/invoke",
"turnBasedEnabled": true,
"turnEndpointUrl": "http://your-agent:8200/turn",
"maxTurns": 15,
"timeoutMs": 120000
}
}
| Field | Type | Default | Description |
|---|---|---|---|
turnBasedEnabled | boolean | false | Enable the AG-UI + A2A dual-protocol model |
turnEndpointUrl | string | {endpointUrl}/turn | Your agent's /turn endpoint |
maxTurns | number | 20 | Maximum turns before the workflow stops |
When turnBasedEnabled is false (default), the agent runs in legacy mode — same single-activity behavior as before. No changes needed for existing agents.
HITL (Human-in-the-Loop) Integration
When a tool requires HITL approval, the platform:
- Pauses the workflow at the
executeToolStepactivity - Sends an SSE event to the UI showing the approval request
- Waits for a Temporal signal (up to 24 hours)
- On approval: re-executes the tool, sends results to your agent
- On rejection: sends a
ToolResultwithstatus: "rejected"andrejectionReason
Your agent should handle rejected tools gracefully:
for tr in tool_results:
if tr["status"] == "rejected":
# Tool was blocked by reviewer — adjust strategy
print(f"Tool rejected: {tr['rejectionReason']}")
# Maybe try a different approach or inform the user
Tool Discovery
On turn 0, the platform includes an availableTools field in the request. This tells your agent which tools it has been granted access to — names, descriptions, JSON input schemas, and conditions (rate limits, HITL requirements).
{
"threadId": "conv-abc123",
"messages": [{ "role": "user", "content": "Research quantum computing" }],
"context": {},
"turnNumber": 0,
"availableTools": [
{
"id": "tool-web-search",
"name": "web_search",
"description": "Search the web using DuckDuckGo for current information.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"limit": { "type": "integer", "description": "Max results", "default": 5 }
},
"required": ["query"]
},
"permissions": ["read", "execute"],
"conditions": { "maxCallsPerHour": 50 }
},
{
"id": "tool-ticket",
"name": "create_ticket",
"description": "Create a support ticket for issue tracking.",
"inputSchema": { "type": "object", "properties": { "title": { "type": "string" } }, "required": ["title"] },
"permissions": ["write", "execute"],
"conditions": { "requireApproval": true }
}
]
}
| Field | Type | Description |
|---|---|---|
id | string | Platform tool ID (e.g., "tool-web-search") |
name | string | Tool name to use in toolCalls[].name |
description | string | Human-readable description |
inputSchema | object? | JSON Schema for tool arguments |
permissions | string[] | Granted permissions: "read", "write", "execute" |
conditions | object? | Grant conditions: maxCallsPerHour, requireApproval, timeWindow |
Key Points
- Turn 0 only — Tools don't change mid-conversation, so
availableToolsis sent only on the first turn. - Backward compatible — If
availableToolsis absent, agents fall back to hardcoded tool definitions. Existing agents work without changes. - D2-filtered — Only tools the agent is explicitly granted appear. Agents cannot discover tools they don't have access to.
- HITL visibility —
conditions.requireApprovaltells the agent which tools will pause for human review. Agents can inform users proactively.
Using Dynamic Tools (Python Example)
@app.post("/turn")
async def turn(body: TurnRequest):
if body.turnNumber == 0 and body.availableTools:
# Build tool schemas dynamically from platform discovery
tool_schemas = [
{"name": t.name, "description": t.description, "parameters": t.inputSchema}
for t in body.availableTools
]
# Use these schemas with your LLM framework instead of hardcoded ones
Backward Compatibility
The dual-protocol model is fully backward compatible:
-
Existing agents: If
turnBasedEnabledisfalse(default), nothing changes. Your agent continues to run as a single Temporal activity. -
Old response format: If your
/turnendpoint returns a response withoutnextAction, the platform wraps it as{nextAction: 'done'}. This means you can gradually adopt the protocol. -
Both endpoints: You can have both
/invoke(legacy) and/turn(contract) on the same agent. The platform calls/turnwhenturnBasedEnabledistrue, falls back to/invokeotherwise.
Framework Examples
See examples/agent-examples/ for complete implementations:
| Framework | File | Key Pattern | Library |
|---|---|---|---|
| Raw Python | raw_python_agent.py | Simplest possible implementation (~50 lines of logic) | None (httpx only) |
| PydanticAI | pydantic_ai_agent.py | ExternalToolset + DeferredToolRequests for declare-but-don't-execute tool calling | pydantic-ai |
| CrewAI | crewai_agent.py | Crew/Agent/Task reasoning per turn, structured JSON output maps to tool declarations | crewai |
| AutoGen | autogen_agent.py | OpenAIChatCompletionClient.create() with tool schemas returns FunctionCall objects, AssistantAgent for synthesis | autogen-agentchat + autogen-ext[openai] |
| Node.js/TS | nodejs_agent.ts | TypeScript SDK with multi-phase HITL: web search → A2UI approval → ticket creation | @agentic-platform/sdk |
All framework agents use their real libraries (not simulations). Each has a mock mode fallback when no LLM API key is configured. Per-agent Dockerfiles keep dependency trees isolated (Dockerfile.pydantic-ai, Dockerfile.crewai, Dockerfile.autogen, Dockerfile.nodejs).
Node.js/TypeScript Agent (SDK-Based)
The nodejs_agent.ts example demonstrates the full TypeScript SDK with multi-segment tool execution and HITL approval. It uses the @agentic-platform/sdk package which provides:
AgenticAgentbase class withonTurn()handler- Automatic
POST /ag-uiSSE endpoint withSTATE_SNAPSHOTand A2UI emission - Automatic
POST /A2A v1.0 JSON-RPC endpoint (with v0.3 compat) GET /.well-known/agent.jsonagent card- Tool result extraction from multi-segment AG-UI messages
// Minimal TypeScript SDK agent
import { AgenticAgent, AgentTurnRequest, AgentTurnResponse, msg, done, serve } from '@agentic-platform/sdk';
class MyAgent extends AgenticAgent {
name = 'my-agent';
requiredTools = [{ toolId: 'tool-web-search', name: 'web_search' }];
async onTurn(request: AgentTurnRequest): Promise<AgentTurnResponse> {
if (request.turnNumber === 0) {
const userMsg = [...request.messages].reverse().find(m => m.role === 'user')?.content ?? '';
return {
messages: [msg('assistant', `Searching: ${userMsg}`)],
toolCalls: [{ id: `s-${Date.now()}`, name: 'web_search', arguments: { query: userMsg } }],
nextAction: 'await_tool_results',
statePatch: { phase: 'awaiting_results' },
};
}
const results = request.toolResults?.map(tr => String(tr.output)).join('\n') || 'No results';
return done(msg('assistant', `Results: ${results}`));
}
}
serve(new MyAgent(), { port: 8000 });
Framework Integration Patterns
PydanticAI has the cleanest fit with the AG-UI + A2A protocols. Its ExternalToolset is a first-class "declare but don't execute" primitive. When the LLM calls a tool, Agent.run() returns DeferredToolRequests with ToolCallPart objects (tool name, args, and call ID). No tool code runs locally. On the next turn, results are fed back via DeferredToolResults + message_history. Message history is serialized via result.all_messages_json() into statePatch for Temporal state persistence.
CrewAI has no native declare-only mode. The pattern uses Crew/Agent/Task for LLM reasoning only (no tools registered on the crew). Three agent personas (Researcher, Analyst, TicketCreator) demonstrate CrewAI's role-based specialization. The crew's structured JSON output is parsed to construct toolCalls[] manually for the platform.
AutoGen uses OpenAIChatCompletionClient.create() with tool schemas to get the LLM's tool call decisions as FunctionCall objects, bypassing AutoGen's automatic execution loop. The function calls map directly to AG-UI/A2A toolCalls[]. On the final synthesis turn, an AssistantAgent (no tools, pure reasoning) demonstrates AutoGen's high-level agent API.
Minimal Example (Python)
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class TurnRequest(BaseModel):
threadId: str
messages: list = []
toolResults: list = []
context: dict = {}
turnNumber: int = 0
@app.post("/turn")
async def turn(body: TurnRequest):
if body.turnNumber == 0:
# Declare a tool call — platform executes it
return {
"messages": [{"role": "assistant", "content": "Searching..."}],
"toolCalls": [{
"id": "tc-1",
"name": "web_search",
"arguments": {"query": body.messages[-1].content}
}],
"nextAction": "await_tool_results",
}
# Process tool results and return final answer
results = body.toolResults or []
answer = "Found: " + str(results[0].get("output", "")) if results else "No results"
return {
"messages": [{"role": "assistant", "content": answer}],
"nextAction": "done",
}
Edge Cases
| Scenario | Behavior |
|---|---|
Agent returns await_tool_results with empty toolCalls[] | Platform treats as done |
maxTurns reached | Workflow breaks the loop, finalizes with accumulated content |
| Unknown tool name | executeToolStep returns status: 'error', sent to agent as ToolResult |
| Temporal worker restart | Deterministic replay — activity results from history, no re-execution |
| Agent endpoint timeout | Temporal retries runAgentTurn (3 attempts, exponential backoff) |
statePatch not JSON-serializable | Temporal codec error — always use plain JSON types |
Architecture
┌─────────────────────────────────────────────────────────┐
│ Temporal Workflow │
│ externalAgentWorkflow │
│ │
│ while (turnNumber < maxTurns) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Activity: runAgentTurn │ │
│ │ HTTP POST → agent /turn endpoint │ │
│ │ Returns: messages, toolCalls, nextAction │ │
│ └──────────────┬───────────────────────────────┘ │
│ │ │
│ if nextAction == "await_tool_results" │
│ │ │
│ ┌──────────────▼───────────────────────────────┐ │
│ │ for each toolCall: │ │
│ │ Activity: executeToolStep │ ←── REUSED from platform-managed agents
│ │ - D2 grant check │ │
│ │ - HITL gate (if required) │ │
│ │ - Execute tool │ │
│ │ - Audit trail │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Activity: executeExternalGuardrailStep │
│ Activity: finalizeExternalWorkflow │
└─────────────────────────────────────────────────────────┘
TypeScript Types
The full TypeScript types are in packages/types/src/agent-contract.ts:
import type {
AgentTurnRequest,
AgentTurnResponse,
AgentMessage,
AgentToolCall,
AgentNextAction,
ToolResult,
} from '@agentic/types';