Agent DocsDeveloper documentation
Reference

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

PathEndpointTransportWhen to Use
SDK-basedPOST /ag-ui (SSE) + POST / (JSON-RPC)AG-UI + A2A v1.0Use the Python/TypeScript SDK — handles everything
Raw turn-basedPOST /turn (JSON)HTTP JSONBuild 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
}
FieldTypeDescription
threadIdstringConversation ID (same across all turns)
messagesAgentMessage[]Full message history including tool results
toolResultsToolResult[]?Results from tool calls your agent requested last turn. Empty/undefined on turn 0.
contextobjectYour agent's statePatch from the previous turn. {} on turn 0. The platform stores this but never reads it.
turnNumbernumber0-indexed turn counter
availableToolsAvailableTool[]?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 }
}
FieldTypeDescription
messagesAgentMessage[]New messages from this turn (shown to user)
toolCallsAgentToolCall[]?Tools you want the platform to execute. Required when nextAction is await_tool_results.
nextActionstringWhat happens next (see below)
statePatchobject?Opaque state for next turn. Must be JSON-serializable.
tokenUsageobject?Token usage for cost attribution

nextAction Values

ValueMeaning
"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"
}
FieldTypeDescription
idstringUnique ID (agent-generated). Used to match results back.
namestringTool name. Must match a tool registered on the platform.
argumentsobjectArguments to pass to the tool.
idempotencyKeystring?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": [...] }
}
StatusMeaning
"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
  }
}
FieldTypeDefaultDescription
turnBasedEnabledbooleanfalseEnable the AG-UI + A2A dual-protocol model
turnEndpointUrlstring{endpointUrl}/turnYour agent's /turn endpoint
maxTurnsnumber20Maximum 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:

  1. Pauses the workflow at the executeToolStep activity
  2. Sends an SSE event to the UI showing the approval request
  3. Waits for a Temporal signal (up to 24 hours)
  4. On approval: re-executes the tool, sends results to your agent
  5. On rejection: sends a ToolResult with status: "rejected" and rejectionReason

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 }
    }
  ]
}
FieldTypeDescription
idstringPlatform tool ID (e.g., "tool-web-search")
namestringTool name to use in toolCalls[].name
descriptionstringHuman-readable description
inputSchemaobject?JSON Schema for tool arguments
permissionsstring[]Granted permissions: "read", "write", "execute"
conditionsobject?Grant conditions: maxCallsPerHour, requireApproval, timeWindow

Key Points

  • Turn 0 only — Tools don't change mid-conversation, so availableTools is sent only on the first turn.
  • Backward compatible — If availableTools is 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 visibilityconditions.requireApproval tells 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:

  1. Existing agents: If turnBasedEnabled is false (default), nothing changes. Your agent continues to run as a single Temporal activity.

  2. Old response format: If your /turn endpoint returns a response without nextAction, the platform wraps it as {nextAction: 'done'}. This means you can gradually adopt the protocol.

  3. Both endpoints: You can have both /invoke (legacy) and /turn (contract) on the same agent. The platform calls /turn when turnBasedEnabled is true, falls back to /invoke otherwise.

Framework Examples

See examples/agent-examples/ for complete implementations:

FrameworkFileKey PatternLibrary
Raw Pythonraw_python_agent.pySimplest possible implementation (~50 lines of logic)None (httpx only)
PydanticAIpydantic_ai_agent.pyExternalToolset + DeferredToolRequests for declare-but-don't-execute tool callingpydantic-ai
CrewAIcrewai_agent.pyCrew/Agent/Task reasoning per turn, structured JSON output maps to tool declarationscrewai
AutoGenautogen_agent.pyOpenAIChatCompletionClient.create() with tool schemas returns FunctionCall objects, AssistantAgent for synthesisautogen-agentchat + autogen-ext[openai]
Node.js/TSnodejs_agent.tsTypeScript 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:

  • AgenticAgent base class with onTurn() handler
  • Automatic POST /ag-ui SSE endpoint with STATE_SNAPSHOT and A2UI emission
  • Automatic POST / A2A v1.0 JSON-RPC endpoint (with v0.3 compat)
  • GET /.well-known/agent.json agent 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

ScenarioBehavior
Agent returns await_tool_results with empty toolCalls[]Platform treats as done
maxTurns reachedWorkflow breaks the loop, finalizes with accumulated content
Unknown tool nameexecuteToolStep returns status: 'error', sent to agent as ToolResult
Temporal worker restartDeterministic replay — activity results from history, no re-execution
Agent endpoint timeoutTemporal retries runAgentTurn (3 attempts, exponential backoff)
statePatch not JSON-serializableTemporal 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';