Config Tag Reference
Every exported field in a Config / Configs struct that carries a wick:"..." tag becomes one editable row in the configs table at boot time, scoped to that module's key. Fields without a tag are ignored.
Tag grammar
Tags are semicolon-separated. A key=value pair sets a named attribute; a bare key is a boolean flag.
type Config struct {
// text input + description
Title string `wick:"desc=Card title shown in the UI."`
// url widget + required + description
Endpoint string `wick:"url;required;desc=API base URL. Example: https://api.example.com"`
// dropdown with fixed options
Mode string `wick:"desc=Conversion mode.;dropdown=uppercase|lowercase|titlecase"`
// multi-line textarea
Template string `wick:"desc=Prompt template.;textarea"`
// number input (also auto-applied for int/float fields)
MaxRows int `wick:"desc=Max rows returned per query.;required"`
// secret/password field
APIKey string `wick:"desc=External API key.;secret;required"`
// toggle switch (auto-applied for bool fields)
EnableCache bool `wick:"desc=Cache results across requests."`
// toggle switch (bool/boolean are aliases of the same control)
DebugMode bool `wick:"bool;desc=Verbose logging."`
// editable table — see kvlist section below
Groups string `wick:"kvlist=id|name;desc=Visible group definitions."`
// override the auto snake_case column name
LegacyKey string `wick:"key=legacy_api_key;secret;desc=Deprecated. Kept for v1 clients."`
}Widget reference
| Tag | Widget rendered | Notes |
|---|---|---|
| (none / default string) | Text input | |
textarea | Textarea | Multi-line |
dropdown=a|b|c | Select | Pipe-separated options |
checkbox / bool / boolean | Toggle switch | All three render the same on/off toggle (track + knob). Auto-applied for Go bool fields; the tags are aliases. |
number | Number input | Auto-applied for int / float fields |
secret | Password input | Masked; value never sent to browser. Shows •••••••• when set |
email | Email input | HTML type="email" |
url | URL input | HTML type="url" |
color | Color picker | HTML type="color" |
date | Date picker | HTML type="date" |
datetime | Date-time picker | HTML type="datetime-local" |
kvlist=col1|col2 | Editable table | Value stored as JSON array — see below |
picker=<source> | Searchable typeahead with chips | Value stored as JSON [{id,name},...]. Requires the parent module to implement a LookupProvider. See below. |
html=<op> | Server-rendered widget | The widget fetches markup from connector op <op> ({html:"..."}) and renders it read-only. Buttons in that HTML drive behaviour via a data-op convention — see below. The core stays domain-agnostic. |
Modifiers (any widget)
| Modifier | Effect |
|---|---|
desc=... | Help text shown below the field in the admin UI |
default=... | Seed value used when the Go field is its zero value ("", 0, false) |
required | c.Missing() / job.Missing() returns this key until it is set |
locked | Read-only in admin UI — set once at boot, not editable post-deploy |
regen | Shows a regenerate button in admin UI — key must have a registered generator |
key=custom_name | Override the auto-derived snake_case key (InitText → init_text) |
visible_when=field:value | Show this field in the admin UI only while another field equals the named value. Use field:a|b|c (pipe-separated) to allow a set. Pure presentation hint — value is still seeded / saved normally. |
mode=fixed / mode=expression | Lock the workflow editor's Fixed ⇄ Expression toggle for this field. mode=fixed forces fixed-literal mode; mode=expression forces template mode. Omit the tag (default) to leave the toggle enabled. Pure presentation hint for the workflow canvas inspector — has no effect on the admin Settings page. Not persisted. |
hidden | Skip the field in the default admin Settings page. Row is still seeded to DB and readable via c.Cfg(...), so runtime works normally — use for fields managed by a dedicated page (e.g. channel setup composers). |
group=Title / group=Title|Description / group=Title|Description|collapsed | Group fields into a titled card on the admin Settings page. All fields sharing the same Title are rendered together under one card, in first-seen order. Fields with no group fall into the default "Configuration" card. The optional Description (pipe-separated) is written once at the top of the card — use it for a one-sentence summary of the group's purpose. A 3rd |collapsed segment makes the card start collapsed (click the header to expand) — use for advanced/rarely-edited groups so the page opens clean; Title||collapsed collapses with no description. Only the group's first-seen field needs to carry the description / collapsed flag. Not persisted. Pure presentation. |
Key derivation
Field names are automatically snake-cased:
| Field | Key |
|---|---|
InitText | init_text |
APIBaseURL | api_base_url |
MaxRetries | max_retries |
Override with key=... when the derived name is wrong or you need to keep a legacy key stable.
kvlist — editable table widget
Use kvlist when a config field holds a dynamic list of structured rows — a set of IDs with labels, a mapping of endpoints, a table of question groups. Not for free-form text; use textarea for that.
type Config struct {
// multi-column: [{"id":"1","name":"Sales"},{"id":"2","name":"Support"}]
Groups string `wick:"kvlist=id|name;desc=Visible question groups."`
// single-column (bare kvlist): [{"value":"ID_001"},{"value":"ID_002"}]
Allowlist string `wick:"kvlist;desc=Allowed sender IDs."`
}Value format — stored in the configs.value column as a JSON array of string-keyed objects:
[{"id":"1","name":"Sales"},{"id":"2","name":"Support"}]Read in Go:
var rows []map[string]string
if err := json.Unmarshal([]byte(c.Cfg("groups")), &rows); err == nil {
for _, row := range rows {
fmt.Println(row["id"], row["name"])
}
}Admin UI behaviour: renders an inline editable table. Rows can be added with + Add Row (or Tab from the last cell) and removed with ×. Changes auto-save 800 ms after the last keystroke — no Save button needed.
When to use kvlist
- Two or more columns per row →
kvlist=col1|col2|col3 - One column only → bare
kvlist(defaults to avaluecolumn) - Free-form multi-line text → use
textareainstead
picker — searchable typeahead widget
Use picker when the legal values come from an upstream directory (a Slack workspace, a Discord guild, a customer table) and the operator should pick chips by name instead of pasting raw IDs.
type Config struct {
AllowedUsers string `wick:"picker=slack.users;desc=Allowed users."`
AllowedChannels string `wick:"picker=slack.channels;desc=Allowed channels."`
}Value format — identical to a 2-column kvlist=id|name:
[{"id":"U123","name":"Yoga"},{"id":"U456","name":"Deva"}]So any whitelist check is just id-membership, and the same parser reads either widget.
Lookup source — the value after = is a registry key (e.g. slack.users). The parent module — typically a channel — must implement LookupProvider:
type LookupProvider interface {
Lookup(source, query string) ([]LookupItem, error)
}
type LookupItem struct{ ID, Name string }The admin UI debounces 250 ms per keystroke and fires GET /channels/<slug>/lookup?source=<src>&q=<q>. Implementations should cap results (~20) and cache aggressively — see slack/lookup.go for a 60 s in-memory cache example.
Admin UI behaviour: a search input with a debounced dropdown; click → chip. Chips have an × to remove. Like kvlist, changes auto-save with no Save button.
When to use picker vs kvlist
- IDs come from an upstream directory the user knows by name →
picker - IDs are arbitrary / freeform / configured locally →
kvlist=id|name
html — server-rendered widget
Use html=<op> when a field's UI is dynamic and owned by the connector, not a fixed input — a live status list, an install/download picker, anything where the backend should decide the layout and the available actions. The core renders whatever markup the op returns and stays completely domain-agnostic (it never knows the field is a "browser" or a "model").
type Config struct {
// The widget fetches markup from the "browser_status" op and renders it.
Browser string `wick:"html=browser_status;default=chromium;desc=Browser engine."`
}The op returns markup — {"html": "<div>…</div>"}. It runs through the manager-only /test path (admin/tag-gated), never MCP. Since the op is still LLM-callable by default, mark maintenance-style ops AdminOnly in the ops table so the agent can't invoke them.
data-op convention — the widget wires two behaviours onto any element in the returned HTML:
| Attribute | Effect on click |
|---|---|
data-op="__select" data-arg="<value>" | Store <value> as this field's value (reserved — no op call). |
data-op="<opKey>" data-arg="<value>" | Run connector op <opKey> (with <value> passed as its argument) via /test, then re-fetch the HTML so state updates. |
// Example browser_status op output — the connector owns all layout + logic:
// <div data-op="__select" data-arg="chromium">Chromium ✓ v133 [selected]</div>
// <div>Firefox — <button data-op="browser_install" data-arg="firefox">Download</button></div>So a Download button installs then refreshes; a row click selects. No SSE — long actions are covered by the re-fetch (poll the status op yourself inside a loop if you want live progress). The markup is admin-only server content rendered in the admin Settings page, so it is {@html}-rendered directly; keep it to connector-authored markup, never user input.
Named inputs — any element with a name inside the widget's markup is collected as input.<name> when an op runs, so a connector can render its own form (e.g. a textarea, a checkbox-based policy editor) and read what the user entered. Collection follows plain HTML form semantics: text/textarea/select send .value; a checkbox or radio sends its value attribute (default "on") only when checked — unchecked contributes nothing, so a boolean setting must be read as "key present", not "value truthy".
visible_when — conditional fields
Hide a field from the admin form until another field equals a target value:
type Config struct {
Mode string `wick:"dropdown=all|whitelist;default=all"`
Allowed string `wick:"picker=slack.users;visible_when=mode:whitelist;desc=Allowed users."`
}For a set of allowed values use a pipe-separated list (OR semantics):
type Config struct {
Method string `wick:"dropdown=GET|POST|PUT|PATCH|DELETE"`
Body string `wick:"textarea;visible_when=method:POST|PUT|PATCH|DELETE;desc=Request body."`
}The field still seeds and persists normally — visible_when only toggles the form row. Useful for cutting noise in config pages with many feature-flagged dependants.
group — config field grouping
Use group to cluster related fields under a titled section card on the admin Settings page. The first field in a group supplies the optional description; subsequent fields with the same title just carry group=Title.
type Config struct {
// First field in a group — sets the title + description for the card.
Mode string `wick:"dropdown=socket|http;group=Connection|Transport credentials. Socket mode needs bot + app token; HTTP mode needs bot token + signing secret.;desc=Connection mode."`
// Subsequent fields — same title, no description needed.
Token string `wick:"secret;group=Connection;desc=Bot token (xoxb-...)."`
Secret string `wick:"secret;group=Connection;desc=Signing secret. Required for http mode."`
// A separate group for access controls.
UsersMode string `wick:"dropdown=all|whitelist;group=Access Control|Who may trigger the agent.;desc=Restrict which users can trigger."`
AllowedUsers string `wick:"picker=slack.users;visible_when=users_mode:whitelist;group=Access Control;desc=Allowed users."`
}Cascading visible_when: a picker or kvlist field inside a group also respects visible_when normally — it only appears when its dependency value matches, even inside the group card.
Fields without a group fall into the default "Configuration" card, which is always rendered last.
Collapsed cards — append |collapsed as the 3rd segment on the group's first field to start that card closed. The admin renders it as an expandable section (a native <details> — a click on the header toggles it). Use for advanced or rarely-edited groups so the page opens uncluttered:
type Config struct {
// Essentials — always visible.
Browser string `wick:"dropdown=chromium|firefox|webkit;group=Browser|The knobs most setups need.;desc=Engine."`
Headless bool `wick:"bool;group=Browser;desc=Headless mode."`
// Advanced — card starts collapsed (Title|Description|collapsed).
ProxyServer string `wick:"group=Network|Route traffic through a proxy.|collapsed;desc=Proxy URL."`
ProxyBypass string `wick:"group=Network;desc=Domains to bypass."`
// Collapsed with no description: Title||collapsed
Channel string `wick:"group=Custom binary||collapsed;desc=Branded channel."`
}Grouping vs hidden
group organises visible fields. hidden hides a field entirely regardless of groups. A field can be both — it simply won't appear in any group card.