August 7, 2026

AI Tool Rendering: A Minimalist Approach to Generative UI

TABLE OF CONTENTS

The core of Recurly's AI initiative is Recurly Compass, an AI assistant that offers increasingly advanced agentic experiences to our users.

As Compass gained tool-calling capabilities, we faced a question that any team building a chat UI for an AI agent eventually hits: what should the interface look like when the agent retrieves structured data? Letting the model narrate tool results in prose works, but it wastes tokens, introduces hallucination risk, and squanders the opportunity to show users something better than text.

Our answer is a pattern we call element rendering — a lightweight contract between the agent and the UI that lets tool responses carry a payload describing *what to render*. The agent becomes a router; the UI handles presentation.

This post walks through a minimal implementation using Google's ADK framework on the backend and React on the frontend.

The Agent

In Google's ADK framework, plain Python functions become **function tools**. ADK automatically generates a schema from the function signature so the model knows when and how to call it.

pip install google-adk

Here's the complete agent:

from google.adk.agents.llm_agent import Agent

def get_weather() -> dict:
    """Returns the current weather conditions"""
    return {
      "status": "success",
      "element": {
        "type": "weather",
        "params": {
          "location": "New York City",
          "condition": "snowy",
          "temperature": {
            "value": "25",
            "unit": "celsius"
          }
        }
      }
    }

root_agent = Agent(
    model='gemini-3-flash-preview',
    name='root_agent',
    description="Shows information about the weather",
    instruction="""
      You are a helpful assistant that retrieves weather data. Use the 'get_weather' tool when asked.
      After calling the tool, respond with a brief affirmative acknowledgment only (e.g. 'Here's the current weather.').
      Do NOT describe or repeat any of the data returned by the tool — the UI will handle rendering it.
    """,
    tools=[get_weather]
)

The key detail is the return value. Rather than returning raw data, the tool wraps its payload in an `element` object with two fields: a `type` string identifying which UI component to render, and a `params` object containing the data that component needs. This is the entire protocol — simple enough to fit in a docstring.

The instruction is equally important: the model is told explicitly not to narrate the tool result. Without this, the model will describe the data in text even though the UI is already rendering it. More on this below.

The API

ADK exposes a REST API. Before sending messages, you create a session:

POST /apps/{app_name}/users/{user_id}/sessions

Messages are then sent to the run endpoint:

POST /run
{
"app_name": "chat_agent",
"user_id": "user_k3m9fx",
"session_id": "abc123",
"new_message": {
"role": "user",
"parts": [{ "text": "What's the weather like?" }]
}
}

The response is an array of events. One for each step the agent took, including the function call, the function result, and the final text reply:

[  {    
    "content": {      
        "role": "model",
              "parts": [{
            "functionCall": {
                "name": "get_weather",
                "args": {}
            }
        }]    
    }  
},    {    
    "content": {      
        "role": "user",
              "parts": [{        
            "functionResponse": {          
                "id": "fn-call-001",
                          "name": "get_weather",
                          "response": {            
                    "status": "success",
                                "element": {              
                        "type": "weather",
                                      "params": {                
                            "location": "New York City",
                                            "condition": "snowy",
                                            "temperature": {
                                "value": "25",
                                "unit": "celsius"
                            }              
                        }            
                    }          
                }        
            }      
        }]    
    }  
},    {    
    "content": {      
        "role": "model",
              "parts": [{
            "text": "Here's the current weather."
        }]    
    }  
}]

Note that the function response event uses the role: "user"` — this is ADK's convention for tool results. The `element` payload travels through this event untouched.

The Frontend

The frontend has two jobs: extract the agent's text reply and the element payload from the events array, then render them.

Types

We define a small set of types. `ChatElement` is the shared contract between the agent's tool responses and the UI's rendering layer:

interface WeatherData {
    location: string
    condition: string
    temperature: {
        value: number;unit: 'celsius' | 'fahrenheit'
    }
}•
interface ChatElement {
    type: 'weather'
    params: WeatherData
}•
interface Message {
    role: 'user' | 'agent'
    text: string | null
    error ? : boolean
    element ? : ChatElement
}•
// Shapes matching ADK's event structure
interface TextPart {
    text ? : string
}•
interface FunctionResponsePart {
    functionResponse: {
        id: string
        name: string
        response: {
            status: string;element ? : ChatElement
        }
    }
}

Sending a Message

`sendMessage` handles the full round trip, creating a new session if needed, posting the message, and extracting both the text reply and any element from the response events:

async function sendMessage(text: string): Promise<Message> {
  const sid = await ensureSession()
  const res = await fetch(`${BASE_PATH}/run`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      app_name: APP_NAME,
      user_id: USER_ID,
      session_id: sid,
      new_message: { role: 'user', parts: [{ text }] },
    }),
  })

  if (!res.ok) throw new Error(`Agent error: ${res.status}`)

  const events: Event[] = await res.json()

  let replyText: string | null = null
  let element: ChatElement | undefined

  for (const event of [...events].reverse()) {
    const { role, parts } = event.content ?? {}

    if (role === 'model' && replyText === null) {
      const part = parts?.find((p): p is TextPart => 'text' in p && !!p.text)
      if (part?.text) replyText = part.text
    }

    if (role === 'user' && element === null) {
      const part = parts?.find((p): p is FunctionResponsePart => 'functionResponse' in p)
      if (part) element = part.functionResponse.response.element
    }
  }

  return { role: 'agent', text: replyText, element }
}

We iterate the events in reverse so that we always capture the *last* text reply and the *last* element, without assuming anything about how many events the agent produces.

Rendering Elements

Each `Message` object now carries both a `text` string and an optional `element`. The chat bubble renders them together:

<div className="...">
  {msg.text}
  {msg.element && <ChatElement element={msg.element} />}
</div>

`ChatElement` maps element types to components. Adding support for a new tool means adding a branch here:

function ChatElement({ element }: { element: ChatElement }) {
  if (element.type === 'weather') {
    return <WeatherCard params={element.params} />
  }

  return null
}

`WeatherCard` is an ordinary React component that knows nothing about the agent or the event parsing above it — it just receives typed props and renders them:

function WeatherCard({ params }: { params: WeatherData }) {
  const icon = WEATHER_ICONS[params.condition.toLowerCase()] ?? '🌡️'
  const unit = params.temperature.unit === 'celsius' ? 'C' : 'F'

  return (
    <div>
      <div>{params.location}</div>
      <div>
        <span>{icon}</span>
        <span>{params.temperature.value}°{unit}</span>
      </div>

      <div>{params.condition}</div>
    </div>
  )
}

Keeping the Agent Focused

Without an explicit instruction, the model will narrate the tool result in text ("The temperature in New York is 25°C and it's snowy") even when the UI is already rendering a card. That creates a redundant experience, and if the model paraphrases, the text and the card can drift apart.

The instruction in our agent is explicit:

> After calling the tool, respond with a brief affirmative acknowledgment only (e.g. 'Here's the current weather.'). Do NOT describe or repeat any of the data returned by the tool — the UI will handle rendering it.

This keeps the agent's text reply as a short conversational handoff rather than a data summary, and lets the rendered component own the presentation.

So what does this all mean?

The pattern is deliberately thin. The contract between agent and UI is a single `element` object in the tool response — a `type` string and a `params` payload. The frontend maps types to components. The agent is told to stay out of the way.

Adding a new tool type requires no changes to the event parsing logic and no new API surface. You add a tool function, define its element shape, write a React component, and add a branch to `ElementRenderer`. The rest stays the same.