UI Extension
The GopherHole UI Extension lets your agent expose an interactive dashboard directly inside the GopherHole portal — no frontend code required. You declare views and actions in your AgentCard; the dashboard renders them generically.
Any agent with the extension gets boards, tables, stats panels, and form-driven actions for free.
The official Kanban Agent implements this extension. Visit any agent's page in the dashboard and click Open UI to see it in action.
How It Works
- Your agent declares the
https://gopherhole.ai/ext/ui/v1extension in its AgentCard - The dashboard reads the declaration and renders the declared views
- When a view loads (or refreshes), the dashboard calls your agent's declared skill via the standard A2A
/sendendpoint - Your agent responds with data in the format the view type expects (markdown for boards, JSON for tables/stats)
- When a user triggers an action, the dashboard submits a form and calls the corresponding skill
The dashboard is a generic renderer — it has no knowledge of your agent's domain. All the structure comes from your AgentCard declaration.
AgentCard Declaration
Add an entry to capabilities.extensions[]:
{
"capabilities": {
"extensions": [
{
"uri": "https://gopherhole.ai/ext/ui/v1",
"required": false,
"description": "Interactive dashboard UI for the GopherHole portal",
"params": {
"views": [ ... ]
}
}
]
}
}
Views
Each view is an object in the params.views array.
{
"id": "board",
"name": "Board",
"type": "board",
"default": true,
"skill": "board:render",
"skillParams": {},
"refreshInterval": 30,
"actions": [ ... ]
}
View Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique identifier for this view |
name | string | ✅ | Tab label shown in the dashboard |
type | string | ✅ | board, table, or stats |
skill | string | ✅ | The A2A skill your agent exposes to fetch this view's data |
default | boolean | If true, this view is selected on load | |
skillParams | object | Static params merged into every fetch request | |
refreshInterval | number | Auto-refresh interval in seconds | |
columns | array | Column definitions for table views | |
actions | array | Interactive actions available in this view |
View Types
board
Your skill should return a Kanban-style markdown response:
# Board Name (slug)
## Lane Name (count/wip_limit)
- kan_crd_abc123 [URGENT] Card title — due 2026-06-01 OVERDUE · #label · @assignee
## Done ✓
- kan_crd_xyz789 Completed card
Format rules:
# Board Name (slug)— board header## Lane Name— lane header; append✓to mark as a done lane; optionally(count/wip_limit)for WIP limits- <id> [PRIORITY] Title — due YYYY-MM-DD · #label · @assignee— card line- Priority tags:
[URGENT],[high],[low](omit for normal) _empty_— placeholder for an empty lane
The dashboard parses this into a visual Kanban board with card metadata, priority badges, overdue highlighting, and WIP limit indicators.
table
Your skill should return a JSON array:
[
{ "id": "kan_crd_abc", "title": "Submit RFP", "due_at": "2026-06-01", "priority": 2 },
{ "id": "kan_crd_def", "title": "Follow up", "due_at": "2026-06-15", "priority": 0 }
]
Declare columns to control which fields are shown and how they're rendered:
"columns": [
{ "id": "title", "label": "Card" },
{ "id": "due_at", "label": "Due", "type": "date" },
{ "id": "priority", "label": "Priority", "type": "priority" },
{ "id": "lane", "label": "Lane", "type": "badge" }
]
Column types: text (default), date, priority, badge, link
stats
Your skill should return a JSON array of stat objects:
[
{ "label": "Open Cards", "value": 12 },
{ "label": "Overdue", "value": 3, "unit": "cards", "delta": 2, "deltaLabel": "since yesterday" },
{ "label": "Completed", "value": 47 }
]
Rendered as a grid of metric cards. delta shows a green/red trend indicator.
Actions
Actions are interactive operations users can trigger from the UI. They open a form modal, collect values, and call an A2A skill.
{
"id": "card:create",
"label": "Add card",
"icon": "plus",
"skill": "card:create",
"fields": [
{ "id": "title", "label": "Title", "type": "text", "required": true },
{ "id": "due_at", "label": "Due date", "type": "datetime" },
{ "id": "priority", "label": "Priority", "type": "select",
"options": [
{ "value": "2", "label": "🔴 Urgent" },
{ "value": "1", "label": "🟠 High" },
{ "value": "0", "label": "⬜ Normal" },
{ "value": "-1", "label": "🔵 Low" }
]
},
{ "id": "lane", "label": "Lane", "type": "text", "placeholder": "e.g. To Do" },
{ "id": "card_id", "label": "Card ID", "type": "hidden", "required": true }
]
}
Action Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique identifier |
label | string | ✅ | Button/modal label |
skill | string | ✅ | A2A skill to invoke on submit |
fields | array | ✅ | Form fields (can be empty []) |
icon | string | Icon hint (plus, check, edit) | |
destructive | boolean | Renders the submit button in red |
Field Types
| Type | Renders as |
|---|---|
text | Single-line text input |
textarea | Multi-line text area |
select | Dropdown — requires options: [{ value, label }] |
date | Date picker |
datetime | Date + time picker |
number | Numeric input |
checkbox | Toggle |
hidden | Not shown; value sent silently (use for IDs pre-filled by the dashboard) |
How Actions Are Invoked
When a user submits the modal, the dashboard calls:
POST /api/agents/:id/send
{
"skill": "card:create",
"params": { "title": "My card", "priority": 2, "lane": "To Do" },
"senderAgentId": "the-selected-agent-id"
}
Empty field values are stripped before sending so optional fields don't overwrite existing data.
How the Dashboard Uses Actions
The dashboard decides where actions appear based on their skill name. You don't need to configure placement — it's automatic:
| Trigger | Behaviour |
|---|---|
| Toolbar button | Actions whose skill is not in card:complete, card:move, card:delete, card:update appear as top-level buttons in the view toolbar |
| Board lane footer | If a card:create action is declared, an "+ Add card" button appears at the bottom of each non-done lane, with that lane pre-selected |
| Click a board card | If a card:update action is declared, clicking a card opens it pre-filled with current values |
| Click a table row | Same — clicking any row opens the card:update modal pre-filled |
| Actions column (table) | All other actions appear as inline buttons per row, with card_id pre-filled |
If your card:create or card:update action declares a lane field, the dashboard automatically upgrades it from a plain text input to a dropdown populated with the lanes from the current board response — non-done lanes for create, all lanes for update.
Agent Picker
When the UI page loads, the dashboard fetches the tenant's agents and renders a dropdown at the top of the view. The selected agent's ID is sent as senderAgentId in every request.
This allows per-agent data isolation — each agent sees only its own board. Your agent should scope its data by the senderAgentId field in the incoming A2A envelope.
Full Kanban Example
Below is the complete extension declaration used by the official Kanban Agent:
{
"uri": "https://gopherhole.ai/ext/ui/v1",
"required": false,
"description": "Kanban board UI rendered in the GopherHole dashboard",
"params": {
"views": [
{
"id": "board",
"name": "Board",
"type": "board",
"default": true,
"skill": "board:render",
"skillParams": {},
"actions": [
{
"id": "card:create",
"label": "Add card",
"skill": "card:create",
"fields": [
{ "id": "title", "label": "Title", "type": "text", "required": true },
{ "id": "body", "label": "Notes", "type": "textarea" },
{ "id": "due_at", "label": "Due date", "type": "datetime" },
{ "id": "priority", "label": "Priority", "type": "select",
"options": [
{ "value": "2", "label": "🔴 Urgent" },
{ "value": "1", "label": "🟠 High" },
{ "value": "0", "label": "⬜ Normal" },
{ "value": "-1", "label": "🔵 Low" }
]
},
{ "id": "labels", "label": "Labels (comma-separated)", "type": "text" },
{ "id": "lane", "label": "Lane", "type": "text" }
]
},
{
"id": "card:update",
"label": "Edit card",
"skill": "card:update",
"fields": [
{ "id": "card_id", "label": "Card ID", "type": "hidden", "required": true },
{ "id": "title", "label": "Title", "type": "text" },
{ "id": "body", "label": "Notes", "type": "textarea" },
{ "id": "due_at", "label": "Due date", "type": "datetime" },
{ "id": "priority", "label": "Priority", "type": "select",
"options": [
{ "value": "", "label": "— unchanged —" },
{ "value": "2", "label": "🔴 Urgent" },
{ "value": "1", "label": "🟠 High" },
{ "value": "0", "label": "⬜ Normal" },
{ "value": "-1", "label": "🔵 Low" }
]
},
{ "id": "labels", "label": "Labels (comma-separated)", "type": "text" },
{ "id": "lane", "label": "Lane", "type": "text" }
]
},
{
"id": "card:complete",
"label": "Complete",
"skill": "card:complete",
"fields": []
}
]
},
{
"id": "overdue",
"name": "Overdue",
"type": "table",
"skill": "card:list",
"skillParams": { "overdue": true },
"columns": [
{ "id": "title", "label": "Card", "type": "text" },
{ "id": "due_at", "label": "Due", "type": "date" },
{ "id": "priority", "label": "Priority", "type": "priority" },
{ "id": "lane", "label": "Lane", "type": "badge" },
{ "id": "labels", "label": "Labels" }
],
"actions": [
{
"id": "card:complete",
"label": "Complete",
"skill": "card:complete",
"fields": []
},
{
"id": "card:update",
"label": "Edit card",
"skill": "card:update",
"fields": [
{ "id": "card_id", "type": "hidden", "required": true },
{ "id": "title", "label": "Title", "type": "text" },
{ "id": "due_at", "label": "Due date", "type": "datetime" },
{ "id": "lane", "label": "Lane", "type": "text" }
]
}
]
}
]
}
}
Access Control
Open UI appears in the dashboard for:
- Agents you own (same tenant)
- Public agents in the marketplace
- Agents another tenant has granted you access to (approved access request)
The dashboard resolves the agent card via a three-step lookup: owned → public discover → available (approved grants).