Every widget implements gomukit.Widget: it renders one self-contained HTML
document (Document()), registers as one ui:// resource
(Descriptor()), and links to tools via ToolMeta(). With the go-sdk
adapter you rarely call these yourself — see gomukit/gosdk.
Widgets read runtime data from the tool result's structuredContent:
| Widget | Key (default) | Shape |
|---|---|---|
| Table | rows (RowsKey) |
[]object — one JSON object per row |
| CardList | rows (RowsKey) |
[]object — one JSON object per card |
| Card | rows (RowsKey) |
[]object — renders the first element (rows[0]) |
| Form | values (PrefillKey) |
{field: value} prefill |
| Form | errors (ErrorsKey) |
{field: "message"} server-side errors |
| Confirm | rows (RowsKey) |
[]object — the record the operation targets (rows[0]) |
| Confirm | effects (EffectsKey) |
[]object — side effects: {text, detail?, value?, severity?} |
| Choice | rows (RowsKey) |
[]object — the record the question is about (rows[0]) |
| Choice | options (OptionsKey) |
[]object — what is on offer: {value, label?, summary?, body?, bullets?, details?, badge?, badgeVariant?, default?, disabled?}, details being [{label, value}] |
| DatePicker | value (ValueKey) |
"YYYY-MM-DD", or {start?, end?, min?, max?, disabled?} — the selection and the window it may move in |
| DatePicker | rows (RowsKey) |
[]object — the record the question is about (rows[0]) |
| Menu | — | reads nothing; its tiles are authored, not fetched |
gomukit.RowsOf(slice) converts typed Go slices to row maps (honors json
tags).
A widget rendered without an InitialData snapshot has no data yet, which is
not the same as having none. It shows a loading skeleton and reaches its empty
state only once data actually resolves: a ui/notifications/tool-result, a
LoadTool response (success or failure), a cancelled call, a handshake nobody
answered, or a 1.5s wait for a host that pushes none of these. An empty
snapshot (InitialData: map[string]any{"rows": []}) counts as resolved, so a
widget you know is empty says so immediately; Empty.Immediate does the same
without a snapshot.
Form gets no skeleton — its fields are structure, not data — but a form with a
LoadTool keeps its controls disabled until the prefill lands or the load
fails, so nothing typed is overwritten. A form without one is typeable from the
first paint.
table := &gomukit.Table{
URI: "ui://myapp/users",
Title: "Users",
Columns: []gomukit.Column{
gomukit.Text("name", "Name"),
gomukit.Number("balance", "Balance", "currency:EUR"),
gomukit.Date("createdAt", "Created", "date"),
gomukit.Badge("status", "Status", map[string]gomukit.BadgeVariant{
"active": gomukit.BadgeSuccess,
"banned": gomukit.BadgeDanger,
}),
gomukit.Link("website", "Website"),
gomukit.ActionsColumn(
gomukit.Action{
Label: "Delete", Tool: "delete_user",
Variant: gomukit.VariantDanger, Confirm: "Really delete?",
Args: map[string]gomukit.ArgSource{"id": gomukit.FromRow("id")},
},
),
},
PageSize: 10,
PageSizes: []int{10, 25, 50}, // page-size dropdown on the pagination bar
DefaultSort: &gomukit.SortSpec{Key: "name"},
Filterable: true,
Selection: &gomukit.SelectionConfig{Bulk: []gomukit.Action{{
Label: "Archive", Tool: "archive_users",
Args: map[string]gomukit.ArgSource{"ids": gomukit.FromSelection("id")},
}}},
Empty: gomukit.EmptyState{Title: "No users"},
}-
Column types:
text,number,date,badge,link,actions. Number formats:int,decimal:<digits>,percent,currency:<code>; date formats:date,datetime,time,relative— all rendered viaIntlin the host's locale/time zone. -
Sorting/filtering/pagination are client-side over the delivered rows. Text/number/date columns sort by default (
Sortableoverrides).PageSizesadds a page-size dropdown to the pagination bar; picking a size returns to the first page, and the bar stays visible even when everything fits on one. -
LoadMorereplaces the pager with a "Load more" bar: the table starts atPageSizerows and appends the nextPageSizeper activation (requiresPageSize > 0, excludesPageSizes).MaxHeight(a CSS length, e.g."20rem") caps the rows area: past it the rows scroll under a sticky header instead of the widget growing — and combined withLoadMore, reaching the bottom of that scroll loads the next batch by itself. Filter or sort changes start the run over at the first batch. -
RowID (default
"id") identifies rows for selection and args. -
Actions:
Kindtool (default) calls an MCP tool;Kindlink opensHrefKeyviaui/open-link. Arg sources:Static(v),FromRow(field),FromSelection(field)(bulk only).Confirmopens a confirmation modal over the frame (nativeconfirm()doesn't work in sandboxed iframes). An actions column renders one "⋯" trigger per row and bulk actions render an "Actions" trigger in the toolbar, each opening a menu of the labels — an actions column costs the same width whether it holds one action or five, and the confirmation is asked on the menu item. -
VisibleWhenlimits an action to the records it applies to, so a switch shows one direction at a time instead of both:gomukit.Action{ Label: "Activate", Tool: "schedule_manage", VisibleWhen: gomukit.RowIs("state", "paused"), Args: map[string]gomukit.ArgSource{"id": gomukit.FromRow("id")}, }
RowIs(key, value)tests one value,RowIn(key, values...)a set, andRowNot(p)the complement; an absent predicate shows the action everywhere. The rows arrive after the document is rendered, so the test travels into the config island and runs per row in the browser — against raw record values, compared strictly by JSON type. Write predicates against machine values (a state code, a flag), never a display label, which the application localises. A row every action excludes renders no "⋯" trigger at all, and hiding a button never changes what the buttons beside it fire. Bulk actions stand over a selection rather than one record and reject the field. -
If an action's result contains
RowsKey, the table re-renders with the returned rows and clears the selection — return the updated list from mutating tools. -
Promptswitches an action to the chat path, for hosts that answer a view-initiatedtools/callwithout opening the widget behind it — see The chat path below.
An action calls its tool from the view and the widget handles the result. That works when the tool answers with data. When it answers with a widget of its own — an edit form opened from a row, a detail view opened from a menu tile — opening it is the host's job, and a host that runs the call out of band opens nothing: the tool runs and the user sees no change.
Setting a prompt routes that action through the host's chat: the view sends
ui/message with the text as a user turn, the model calls the tool, and the
widget arrives as that call's result.
// Table row / card action
gomukit.Action{Label: "Edit", Tool: "edit_user",
Prompt: "Open the edit form for this user"}
// Menu tile
gomukit.MenuItem{Tool: "list_customers", Label: "Customers",
Prompt: "Show me the customer list"}
// Confirm, Choice, DatePicker — named ChatPrompt, since Prompt is the
// question already put to the reader
gomukit.AcceptSpec{Tool: "delete_user",
ChatPrompt: "Delete the account for Ada"}- Write it as the request a user would type; the model decides which tool
answers, so
Tooldocuments what the action opens and stays required. Argsare dropped: the model chooses the arguments. The text is fixed and carries no row values.- There is no result to inspect, so the widget only reports that the turn was accepted, or shows an error if the host refused it.
- Link actions may not set
Prompt— a link already navigates on its own. ChoiceandDatePickerappend the reader's decision to the text, since a chat turn has no argument to carry it:"Ship order ORD-4471 — chose: Express","Book the room — chose: 2026-07-20 to 2026-07-24". Dates go as ISO whatever the grid rendered; options go by label, since a person reads it.
form := &gomukit.Form{
URI: "ui://myapp/user-form",
Title: "Edit user",
Fields: []gomukit.Field{
{Name: "id", Type: gomukit.FHidden},
{Name: "name", Label: "Name", Required: true},
{Name: "email", Label: "Email", Required: true,
Validation: &gomukit.Validation{
Pattern: `[^@\s]+@[^@\s]+`, Message: "Enter a valid email.",
}},
{Name: "role", Label: "Role", Type: gomukit.FSelect, Required: true,
Options: []gomukit.Option{gomukit.Opt("user"), gomukit.Opt("admin")}},
{Name: "active", Label: "Active", Type: gomukit.FCheckbox, Default: true},
},
Submit: gomukit.SubmitSpec{Tool: "save_user", SuccessMessage: "Saved."},
Cancel: &gomukit.CancelSpec{},
}- Field types:
text,textarea,number,checkbox,select,multiselect,date,daterange,time,hidden,readonly. - Groups and layout:
FieldSetsare titled groups (Title,Description,Fields, andBoxedfor a bordered panel), rendered after the form's own ungroupedFields.Columnsputs several fields on a row — 1 to 4, set on the form and overridable per field set — andField.Spanlets one field take several of its group's columns. The grid gives columns back as the widget narrows (three or four drop to two under 46rem, everything to one under 34rem), so the same document reads in a chat pane and in a wide panel. Groups change nothing about submission: a grouped field shares the same name namespace and submits exactly like an ungrouped one. - Dropdowns:
selectandmultiselectrender as the gomukit dropdown — the runtime upgrades the<select>into a trigger plus popup listbox (arrow keys, Home/End, typeahead, Escape, check marks) and keeps the select as the value holder, so validation and submitted value types are unchanged.Placeholderbecomes the trigger's empty-state text. - Date fields:
FDateandFDateRangerender the gomukit calendar — the runtime upgrades the native date input into a trigger showing the date in the host's locale, with the grid in a popover, and keeps the input as the value holder (so validation and the submitted value are unchanged, and a document whose script never runs still has a working date control).Field.Calendarconfigures the grid exactly as it configures theDatePickerwidget: bounds, blocked days, presets, month travel — see DatePicker. A range sends two flat arguments:Namecarries the start andEndName(defaultName + "_end") the end, both"YYYY-MM-DD".Defaulttakes[]string{start, end}. An optional field's popover offers a Clear button. - Client validation renders as native HTML attributes (
required,pattern,min/max/step,minlength/maxlength) and is enforced before submit, with inline error messages (Validation.Messageoverrides the browser text). - Submit is driven by the submit button's click, not by native form
submission: hosts sandbox the widget iframe without
allow-forms, which blocks a real submit before its event fires. Enter in a single-line field submits too. It callsSubmit.Toolwith{field: value}merged overStaticArgs. Value types: checkbox → bool, number → number (omitted when empty), multiselect → []string, everything else → string (hidden fields included — parse server-side). - Server-side errors: return
{ErrorsKey: {"field": "message"}}instructuredContent; they render inline and mark the form failed. - Edit mode: a model-invoked tool linked to the form (e.g.
edit_user) returns{PrefillKey: {...}}; the form prefills from the tool result.
form := &gomukit.Form{
URI: "ui://myapp/employee", Title: "New employee",
Columns: 2, // two fields per row, everywhere below unless overridden
Fields: []gomukit.Field{
// Ungrouped fields come first. Span 2 = the whole row.
{Name: "workspace", Label: "Workspace", Type: gomukit.FReadonly, Span: 2},
},
FieldSets: []gomukit.FieldSet{
{
Title: "Person",
Description: "How the record reads everywhere it appears.",
Fields: []gomukit.Field{
{Name: "first", Label: "First name", Required: true},
{Name: "last", Label: "Last name", Required: true},
{Name: "email", Label: "Email", Required: true, Span: 2},
},
},
{
Title: "Employment", Boxed: true, // a bordered panel
Fields: []gomukit.Field{
{Name: "role", Label: "Role", Type: gomukit.FSelect,
Options: []gomukit.Option{gomukit.Opt("engineer"), gomukit.Opt("designer")}},
{Name: "hours", Label: "Weekly hours", Type: gomukit.FNumber},
},
},
{
Title: "Notes", Columns: 1, // one column, whatever the form says
Fields: []gomukit.Field{
{Name: "notes", Label: "Anything the team should know", Type: gomukit.FTextarea},
},
},
},
Submit: gomukit.SubmitSpec{Tool: "create_employee", Label: "Create employee"},
}Card renders one record; CardList renders many records as cards in a
horizontally scrolling strip (a carousel) with the same client-side machinery
as Table (filter, sort, pagination, selection + bulk actions, per-card
actions). The strip is what makes a collection usable in a chat pane, where a
table overflows and a card grid turns into a long vertical scroll. Both share a
CardTemplate and read the same rows contract as Table — so the same
list/mutation tools drive either.
tmpl := gomukit.CardTemplate{
Header: gomukit.CardHeader{
TitleKey: "name",
DescriptionKey: "email",
Badge: gomukit.Badge("status", "Status", map[string]gomukit.BadgeVariant{
"active": gomukit.BadgeSuccess,
"banned": gomukit.BadgeDanger,
}),
},
Content: gomukit.CardContent{
TextKey: "bio",
Items: gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "Balance", Key: "balance", Type: gomukit.ColNumber, Format: "currency:EUR"},
{Label: "Joined", Key: "createdAt", Type: gomukit.ColDate, Format: "relative"},
{Label: "Website", Key: "website", Type: gomukit.ColLink,
Link: &gomukit.LinkSpec{HrefKey: "website"}},
}},
},
Footer: gomukit.CardFooter{
Text: "Balances update hourly.",
Actions: []gomukit.Action{
{Label: "Edit", Tool: "edit_user", Variant: gomukit.VariantPrimary,
Args: map[string]gomukit.ArgSource{"id": gomukit.FromRow("id")}},
},
},
}
cards := &gomukit.CardList{
URI: "ui://myapp/users",
Title: "Users",
Template: tmpl,
PageSize: 12,
PageSizes: []int{12, 24, 48},
DefaultSort: &gomukit.SortSpec{Key: "balance", Desc: true},
Filterable: true,
Selection: &gomukit.SelectionConfig{Bulk: []gomukit.Action{{
Label: "Archive", Tool: "archive_users",
Args: map[string]gomukit.ArgSource{"ids": gomukit.FromSelection("id")},
}}},
Empty: gomukit.EmptyState{Title: "No users"},
}
card := &gomukit.Card{URI: "ui://myapp/user", Title: "User", Template: tmpl}-
Template: three sections, rendered in order.
Header—TitleKey(required) andDescriptionKey/Descriptionname the record, and one end slot holds eitherBadge(a badge column, for a status pill) orAction(a button). Not both: it is one slot.Content—TextKey/Textis a paragraph of body prose, andItemsis the sharedDescriptionsblock (same types andFormatstrings as table columns; a value the record lacks renders as—).Footer—TextKey/Textis a note beside the buttons inActions.
A section with nothing in it is not rendered, so a title-and-actions card carries no empty chrome.
-
CardList reuses
RowsKey/RowID,PageSize,PageSizes,DefaultSort,Filterable, andSelectionexactly asTable. The sort control is a dropdown auto-derived from the sortable content items; filtering matches the title, description, body text and item values. -
Carousel: prev/next controls appear only when the cards overflow the available width and disable at each end. The strip drags with the mouse, swipes on touch, scrolls with the arrow keys when focused, and hides its scrollbar.
PageSizestill applies and bounds how many cards sit in the strip at once. Card width is the--gomu-card-widthtoken (default17rem), overridable per widget:Theme: &theme.Theme{Extra: map[string]string{"--gomu-card-width": "22rem"}}. -
Card renders
rows[0]; use it as a detail view. Both supportInitialDataandLoadTool/LoadArgsload-time hydration, and re-render when an action or tool result returnsRowsKey. -
Actions behave exactly as in tables (
Static/FromRowargs, inlineConfirm,Variant,VisibleWhen);FromSelectionis bulk-only viaCardList.Selection. A record whose every action is hidden by its predicate renders no action bar, and no footer at all when the footer holds nothing else.
A launcher: a responsive grid of tiles, one per tool the server exposes with a UI. Choosing a tile calls that tool and the host opens the widget bound to it, so a menu is the entry point an app hands the user before any record is in view.
menu := &gomukit.Menu{
URI: "ui://myapp/menu",
Title: "Acme users",
Intro: "Pick where to start.",
Items: []gomukit.MenuItem{
{
Tool: "list_users",
Label: "User table",
Description: "Sortable, filterable directory.",
IconSVG: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M9 10v10"/></svg>`,
Badge: "read",
BadgeVariant: gomukit.BadgeInfo,
},
{Tool: "edit_user", Args: map[string]any{"id": 1}, Label: "Edit Ada"},
},
Brand: brand,
}
// The tool that shows the menu returns no structured data of its own.
type empty struct{}
gosdk.AddWidgetToolFor(server, menu,
&mcp.Tool{Name: "main_menu", Description: "Show the app menu."},
func(context.Context, *mcp.CallToolRequest, empty) (*mcp.CallToolResult, empty, error) {
return nil, empty{}, nil
})- Authored, not fetched: unlike the data widgets, the tiles are rendered
server-side from
Items. There is noInitialData, noLoadTool, and nostructuredContentkey — the document is complete the moment it is registered. Argsare fixed values. A tile has no record behind it, soStatic/FromRow/FromSelectiondo not apply.Promptswitches a tile to the chat launch path, for hosts that run a view-initiatedtools/callout of band and so never open the bound widget. The view sendsui/messagewith the text as a user turn, the model calls the tool, and the widget arrives as that call's result. Write it as the request a user would type;Argsare dropped for such a tile, since the model chooses the arguments. Tiles of both kinds can sit in one menu.Labeldefaults toTool, so a bare{Tool: "list_users"}still renders a usable tile.IconSVGis inline markup, never a URL, with the same safety checks asBrand.LogoSVG.Badge/BadgeVariantadd a short marker ("read", "beta") in the tile's top right.- While a tile is firing, the whole grid is disabled — a second tile would
race the first one's view swap — and the status region reads
"Opening <label>…". A result with
isErroris shown there and the menu stays usable; otherwise nothing is rendered, because the host takes over. APrompttile has no result to inspect and clears its status as soon as the host accepts the turn. - Layout reflows via CSS
auto-fill, so one document works in a narrow chat pane and a wide panel. Minimum tile width is the--gomu-menu-tile-mintoken (default11rem), overridable per widget:Theme: &theme.Theme{Extra: map[string]string{"--gomu-menu-tile-min": "14rem"}}.
An approval view: it states an operation, shows the record it targets and the
side effects it will have, and offers exactly two outcomes. This is the long
form of Action.Confirm — that one re-labels a button for a second click and
has room for a few words; use Confirm when the consequences have to be
weighed before the reader decides.
confirm := &gomukit.Confirm{
URI: "ui://myapp/delete-user",
Title: "Delete user",
Prompt: "Delete Ada Lovelace?",
Body: "The account and everything attached to it is removed for good.",
Severity: gomukit.BadgeDanger,
Details: gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "User", Key: "name"},
{Label: "Balance", Key: "balance", Type: gomukit.ColNumber, Format: "currency:EUR"},
{Label: "Status", Key: "status", Type: gomukit.ColBadge, Badge: map[string]gomukit.BadgeVariant{
"active": gomukit.BadgeSuccess,
}},
{Label: "Region", Text: "eu-central-1"},
}},
Effects: []gomukit.Effect{
{Text: "Removes the account", Severity: gomukit.BadgeDanger},
{Text: "Deletes audit records", Value: "128", Severity: gomukit.BadgeWarning},
},
Acknowledge: "I understand this cannot be undone.",
TypeToConfirm: "ada@example.com",
Accept: gomukit.AcceptSpec{
Tool: "delete_user", Label: "Delete user",
Args: map[string]gomukit.ArgSource{"id": gomukit.FromRow("id")},
SuccessMessage: "User deleted.",
},
Reject: &gomukit.RejectSpec{Label: "Keep user", Message: "Nothing was deleted."},
}- Severity (
BadgeInfodefault,BadgeWarning,BadgeDanger) colors the icon and picks the accept button's variant — danger gets the danger button, anything else the primary one.Accept.Variantoverrides that. - Details is a
Descriptionsblock bound torows[0], the same record contract asCard. Effects are the consequences, one row each:Text, an optionalDetailline, an optionalValueshown at the row end ("128", "4 people"), and aSeveritythat colors the row's dot. - Authored now or delivered later: the effects written in Go are what the
widget shows until a tool result arrives with its own under
EffectsKey, which replaces the list wholesale. UseInitialDatafor a baked snapshot andLoadTool/LoadArgsto fetch the record and effects fresh on load — worth it here, since the consequences should be current at decision time rather than at registration time. - Guards add friction before the accept button enables:
Acknowledgerenders a checkbox that must be ticked,TypeToConfirma phrase that must be typed exactly. Either, both or neither; the button rendersdisabledserver-side when any is configured, so the document is correct before the runtime mounts. - Accept calls
Accept.ToolwithStatic/FromRowargs (FromSelectionis rejected — there is no selection). On success the buttons are replaced bySuccessMessage, or by the result's own text. A result withisErrorleaves the widget usable so a transient failure can be retried. - Reject is optional. Without
Reject.Tooldeclining is a local outcome the server never hears about; set the tool when the operation needs an explicit "no". - The decision is final: once accepted or declined the buttons are gone and the outcome stays on screen, even if the host pushes later results.
A deciding view: a question, the options answering it, and the case for each
one. Picking is local — nothing is called until the reader submits. Where
Confirm asks yes/no about one operation, Choice asks which operation.
choice := &gomukit.Choice{
URI: "ui://myapp/shipping",
Title: "Shipping",
Prompt: "How should we ship order ORD-4471?",
Body: "The parcel is packed and leaves the warehouse today either way.",
Details: gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "Order", Key: "reference"},
{Label: "Destination", Text: "Berlin, DE"},
}},
Options: []gomukit.ChoiceOption{
{
Value: "standard", Label: "Standard", Summary: "3-5 business days",
Body: "Handed to the postal service tonight.",
Bullets: []string{"Tracked to the depot", "No signature on delivery"},
Details: gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "Price", Key: "price", Type: gomukit.ColNumber, Format: "currency:EUR"},
{Label: "Arrives", Key: "eta", Type: gomukit.ColDate, Format: "date"},
}},
Data: map[string]any{"price": 4.9, "eta": "2026-08-03T10:00:00Z"},
Default: true,
},
{
Value: "express", Label: "Express", Summary: "next business day",
Body: "Arrives before 12:00 tomorrow.",
Badge: "fastest",
BadgeVariant: gomukit.BadgeSuccess,
},
{Value: "pickup", Label: "Depot pickup", Summary: "no depot nearby", Disabled: true},
},
Submit: gomukit.ChoiceSubmit{
Tool: "ship_order", Label: "Ship it", ValueArg: "method",
Args: map[string]gomukit.ArgSource{"id": gomukit.FromRow("id")},
SuccessMessage: "On its way.",
},
Cancel: &gomukit.RejectSpec{Label: "Decide later", Message: "Nothing was shipped."},
}- An option argues for itself.
LabelandSummaryare the list;Body,BulletsandDetailsare the description block.Detailsitems typed with aKeyread the option's ownDatarecord, so prices and dates are formatted for the host's locale instead of being baked into strings. - Layout is about width, not taste.
ChoiceSplitputs the description in a side panel that follows the option in hand;ChoiceStackedunfolds it inside the chosen option. The default,ChoiceAuto, measures the width the host gave the widget — side panel at or above34rem, stacked below — and re-measures as the pane resizes, so the same document reads in a wide canvas and a narrow chat column. - One or several. Without
Multiplethe options are radios and the last pick wins. With it they are checkboxes bounded byMin(default 1) andMax(0 = no limit): submit stays disabled until the floor is met, the unticked options disable at the cap rather than failing on click, and a hint under the list tracks the count. - Authored now or delivered later: the options written in Go are what the
widget shows until a tool result arrives with its own under
OptionsKey, which replaces the list wholesale and re-applies the new defaults. Options from a tool describe themselves in plain values —detailsthere is[{label, value}], already formatted server-side.LoadTool/LoadArgsfetch them fresh on load, which is what a server pricing shipping at call time wants. - Submit calls
Submit.Toolwith itsStatic/FromRowargs plusValueArg(default"choice"): the chosenValue, or the array of chosen values in option order. On success the controls are replaced bySuccessMessage, or by the result's own text; a result withisErrorleaves the widget usable so a transient failure can be retried. Disabledoptions are never chosen, but pointing at one still shows its description — why it cannot be taken is the thing worth reading.- The decision is final: once submitted or cancelled the controls are gone and the outcome stays on screen, even if the host pushes later results.
Descriptions is a shared block, not a widget: a label/value detail list used
by Confirm, Choice (both for the record and per option), DatePicker and a
card's content. Each item takes its value from a record field (Key, typed and
Intl-formatted exactly like a table cell) or from fixed
text authored in Go (Text) — one or the other, never both. An item can also
ask for a value instead of stating one (Input, below).
gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "User", Key: "name"},
{Label: "Joined", Key: "createdAt", Type: gomukit.ColDate, Format: "relative"},
{Label: "Profile", Type: gomukit.ColLink, Link: &gomukit.LinkSpec{HrefKey: "website"}},
{Label: "Region", Text: "eu-central-1"},
}}- Item types are the
Columntypes minusactions:text(default),number,date,badge,link, with the sameFormatstrings. - No layout options. The list takes as many columns as the widget's own
width allows and collapses to one in a narrow pane. The smallest an item may
get before a column is dropped is the
--gomu-desc-mintoken (default12rem), overridable per widget:Theme: &theme.Theme{Extra: map[string]string{"--gomu-desc-min": "16rem"}}. - A value the record does not carry renders as an em dash rather than disappearing: a reader deciding on these facts should see which are missing.
An item with an Input renders a control in its value cell, and what the
reader puts in it travels with the widget's own call — the question a widget
asks in passing, rather than a form of its own.
gomukit.Descriptions{Items: []gomukit.DescriptionItem{
{Label: "Booking", Key: "reference"},
{Label: "Guests", Key: "guests", Input: &gomukit.Input{
Name: "guests", Type: gomukit.InputNumber, Default: 2, Required: true,
Validation: &gomukit.Validation{Min: &one, Max: &six, Message: "Between one and six."},
}},
{Label: "Bed", Input: &gomukit.Input{
Name: "bed", Type: gomukit.InputSelect, Placeholder: "Pick one",
Options: []gomukit.Option{{Value: "double", Label: "One double"}, {Value: "twin", Label: "Two singles"}},
}},
{Label: "Arriving after 22:00", Input: &gomukit.Input{Name: "late", Type: gomukit.InputCheckbox}},
}}- Four control types:
InputText(the default),InputNumber,InputSelect(a gomukit dropdown overOptions) andInputCheckbox. A number arrives as a number and a checkbox as a bool; an empty number sends no argument at all. - Only widgets that own a call take them.
DatePicker.Detailsmerges the values into the submit call beside the picked date; a card'sContent.Itemsmerge them into every action button of that card (per record — aCardListkeeps each card's answers to itself, and bulk actions get none). AnInputinConfirm.Detailsor aChoice's details is a validation error: nothing there would carry it. Nameis a tool argument and must not collide with anything else the call already builds — the date arguments,Submit.Args, an action'sArgs.- Prefill order: the reader's own answer, else the record field named by
Key, elseDefault. Answers survive re-renders, so a tool result landing mid-answer replaces the values around the control, not in it. - Validation is native and runs before the call: a required control that is
empty, or one outside its bounds, blocks it and shows
Validation.Message(or the browser's own text) under the control.
A date, or the span between two, as the whole question. It is the standalone
form of the same calendar FDate and FDateRange open in a form: use the
widget when the date is the question ("when should this ship?", "which
nights?"), and the field when it is one answer among several.
picker := &gomukit.DatePicker{
URI: "ui://myapp/booking",
Title: "Booking",
Prompt: "Which nights should we hold the suite?",
Mode: gomukit.DateRange,
Calendar: &gomukit.Calendar{
Min: "2026-08-01",
Max: "2026-12-31",
Disabled: []string{"2026-08-27", "2026-08-28"},
WeekNumbers: true,
Presets: []gomukit.DatePreset{
{Label: "This week", Span: gomukit.SpanThisWeek},
{Label: "Trade fair", Start: "2026-09-07", End: "2026-09-11"},
},
},
Submit: gomukit.DateSubmit{
Tool: "hold_room", Label: "Hold it",
ValueArg: "from", EndArg: "until", SuccessMessage: "Held.",
},
Cancel: &gomukit.RejectSpec{},
}- The grid is inline, not behind a trigger: a view whose only job is a calendar should not ask to be opened. Picking is local; only the submit button calls a tool, and it stays disabled until the selection is something the reader could submit (both ends, in a range).
- Dates are days, not instants. Everything travels as
"YYYY-MM-DD": no time, no zone, no offset. The host's time zone is used for exactly one thing — deciding which day is today, so a widget read in Auckland does not ring yesterday because the server is in Berlin. - Submit calls
Submit.Toolwith itsStatic/FromRowargs plusValueArg(default"date", or"start"in a range) and, in a range,EndArg(default"end"). Two flat string arguments, so a tool schema can declare two dates and a server can read them without unpacking anything. - Runtime state arrives under
ValueKey(default"value"): either a date string, or an object carrying the selection and the grid's limits —{start, end, min, max, disabled}. Which days are still free is exactly the kind of thing that changes between registration and the question, soLoadTool/LoadArgsfetch it fresh on load. Detailsdescribes the record the question is about (fromrows[0]), the same blockConfirmandChoiceuse — and, here, one that can ask as well as state: an item with anInputrenders a control above the grid whose value travels with the submit call (see Descriptions → Items that ask). WithChatPromptset there are no arguments to fill, so the answers are appended to the message text instead.- The decision is final: once submitted or cancelled the controls are gone and the outcome stays on screen, even if the host pushes later results.
Calendar is a shared block, not a widget: the same grid configuration for the
DatePicker widget and for a form's FDate/FDateRange fields. The zero
value (a nil *Calendar) is a one-month grid with every day selectable, which
is what most fields want.
| Field | Effect |
|---|---|
Min, Max |
The selectable window, "YYYY-MM-DD". The grid will not travel past the months holding them, and date fields render them as native min/max too. |
Disabled |
Individual days that cannot be picked — holidays, days already booked. A range may not straddle one. |
DisableWeekends |
Blocks every Saturday and Sunday. |
Months |
Months shown at once; defaults to 1 for a date and 2 for a range, maximum 4. They sit side by side where there is room and fall onto a second row where there is not — every month asked for is always on screen, so a span across a boundary is one gesture in a chat pane too. |
WeekNumbers |
A leading column of ISO 8601 week numbers. |
MonthDropdowns |
Month and year dropdowns in place of the caption, bounded by FromYear/ToYear (defaulting to the years of Min/Max). For dates of birth and anything else far from today. |
WeekStart |
Overrides the first day of the week; defaults to the host locale's own (WeekStartMonday, WeekStartSunday, WeekStartSaturday). |
StartOn |
The month the grid opens on while nothing is selected. |
Presets |
Named shortcuts: a rail beside the grid where there is room for one, a row above it otherwise. |
DatePreset names a window with either a fixed Start/End or a Span
resolved at runtime against the reader's today: SpanToday, SpanYesterday,
SpanTomorrow, SpanLast7Days, SpanLast30Days, SpanLast90Days,
SpanNext7Days, SpanNext30Days, SpanThisWeek, SpanLastWeek,
SpanThisMonth, SpanLastMonth, SpanThisYear, SpanYearToDate. A span is
the name of a rule rather than a pair of dates because a server cannot name
those dates at registration time: "the last 7 days" is a different week by the
time the widget is read. In a single-date calendar a preset picks the day its
window opens on.
A preset is measured against Min/Max and the blocked days as they stand when
the widget is read, and is shown as unavailable where it has nothing to offer —
a lit shortcut always does something. A range preset that overlaps the bounds is
trimmed to them: "last 30 days" against a calendar that opens on the 1st picks
the days of it there are. One wholly outside the bounds, or straddling a blocked
day, is switched off, since a shorter window would name something nobody asked
for. A single-date preset is never moved: it picks the day it names or is
switched off, so a "Today" before Min does not quietly pick another day.
- Keyboard: one tab stop for the whole grid, arrows by day and week, PageUp/PageDown by month (with Shift, by year), Home/End to the ends of the week, Enter or Space to pick, Escape to close a popover. A blocked day still takes focus — passing over it is how you get past it.
- Locale: month and weekday names, the first day of the week and the formatted value all come from the host's locale, which is why the grid is built at runtime rather than server-rendered.
Every widget takes an optional Brand: a logo, a name, and a link identifying
the application the widget belongs to. One *Brand is normally shared by all
of a server's widgets.
brand := &gomukit.Brand{
Name: "Acme",
URL: "https://acme.example", // opened through the host
LogoSVG: `<svg viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="7"/></svg>`,
}
table := &gomukit.Table{URI: "ui://myapp/users", Brand: brand /* … */}- Placement: always the bottom left of the widget, leading the status bar
ahead of the runtime's message. It is not part of the toolbar, so a widget
with a brand and no
Titlerenders no toolbar at all. - Logo: prefer
LogoSVG(inline markup, nothing needed from the host's CSP).LogoDataURItakes adata:image/...;base64,...string and renders an<img>, which depends on the host allowingimg-src data:— not guaranteed by the spec. External logo URLs are never allowed: documents stay self-contained. - Safety:
LogoSVGis author input, checked against script-bearing and resource-loading constructs (<script>,on*=handlers,<use>,<foreignObject>,javascript:, …); a violation failsValidate()and thereforeDocument(). - Link: a branded widget renders a button, not an anchor — navigation is
blocked inside the host's sandboxed iframe, so the URL goes to the host via
ui/openLink.
server := mcp.NewServer(&mcp.Implementation{Name: "myapp"}, gosdk.EnableUI(nil))
// Model-visible tool rendered by the table:
gosdk.AddWidgetToolFor(server, table,
&mcp.Tool{Name: "list_users", Description: "List users."}, listUsers)
// App-only tool (fired by row actions, hidden from the model):
del := &mcp.Tool{Name: "delete_user", Description: "Delete a user."}
gosdk.AppOnly(del, table)
gosdk.AddWidgetToolFor(server, table, del, deleteUser)examples/demo is a complete runnable server; examples/harness is a fake
host for manual verification without any MCP client.