Interactive Forms¶
Forms let bots send interactive HTML to users. When a user fills and submits a form, the response is routed to any bot that subscribes to the matching label. This is message-based routing — not RPC.
How it works¶
sequenceDiagram
participant BotA as Bot-A
participant Server
participant Browser
participant BotB as Bot-B
participant BotC as Bot-C
BotA->>Server: send_form("How was your day?")
Server->>Browser: broadcast (interactive-form label)
Browser->>Browser: render HTML form
Browser->>Server: form_submission (answer_label + response data)
Server->>Server: evaluate label subscriptions
par Bot-A matches own label
Server->>BotA: MESSAGE (form_submission)
BotA->>Server: "Thanks for filling out!"
and Bot-B matches wildcard
Server->>BotB: MESSAGE (form_submission)
BotB->>BotB: archive to database
and Bot-C matches origin
Server->>BotC: MESSAGE (form_submission)
BotC->>Server: shy emoticon
end
Key insight: the bot that creates the form is not necessarily the bot that processes the response. Any bot subscribed to a matching label receives the submission.
Sending a form¶
from meadows.bot import BaseBot
class MyBot(BaseBot):
BOT_NAME = "poll"
def handle(self, command, args, raw_args, message, thread_context):
if command == "ask":
self.send_form(
content="Quick poll: how are you today?",
answer_label=("bot-poll", "poll-response", "1.0.0"),
form_html="""
<label>Mood: <select name="mood">
<option value="happy">Happy</option>
<option value="ok">OK</option>
<option value="sad">Sad</option>
</select></label>
<label>Notes: <input name="notes" placeholder="optional"></label>
<button type="submit">Submit</button>
""",
group_id=message["group_id"],
)
return None # send_form handles the message
Parameters¶
| Parameter | Type | Required | Description |
|---|---|---|---|
content |
str |
Yes | Human-readable description. Shown in TUI and as fallback. |
answer_label |
tuple[str, str, str] |
Yes | (origin, label, semver) triplet for routing responses. |
form_html |
str |
No | HTML form content. Stored in metadata, rendered by GUI. |
group_id |
str |
No | Target group. Defaults to "general". |
What send_form() does¶
- Creates a
Messagewithtype=BOTand theinteractive-formlabel - Places
answer_labelandform_htmlinmetadata['meadows']['form_handling'] - Auto-wraps bare HTML in
<form>tags if no<form>is present - Emits the message to the server
The message that arrives at the frontend looks like:
{
"type": "bot",
"content": "Quick poll: how are you today?",
"labels": [
["meadows", "interactive-form", "1.0.0"],
["bot-poll", "poll-response", "1.0.0"]
],
"metadata": {
"meadows": {
"form_handling": {
"answer_label": ["bot-poll", "poll-response", "1.0.0"],
"form": "<form>...</form>"
}
}
}
}
Receiving form submissions¶
Subscribe to the answer_label you defined when sending the form:
class MyBot(BaseBot):
BOT_NAME = "poll"
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.client.on(EventName.MESSAGE, self._on_message)
def _on_message(self, data):
if data.get("type") != MessageType.FORM_SUBMISSION.value:
return
# Extract response from metadata
response = data.get("metadata", {}).get("meadows", {}).get("form_handling", {}).get("response", {})
mood = response.get("mood", "unknown")
notes = response.get("notes", "")
# Identify which form was submitted
labels = data.get("labels", [])
for lbl in labels:
if isinstance(lbl, (list, tuple)) and len(lbl) >= 2:
if lbl[1] == "poll-response":
group_id = data.get("group_id", "general")
self._send_text(group_id, f"Poll result: {mood} ({notes})")
break
if __name__ == "__main__":
bot = MyBot()
bot.register_label_subscription(
name="poll-responses",
predicate={
"and": [
{"==": [{"var": "origin"}, "bot-poll"]},
{"==": [{"var": "label"}, "poll-response"]},
{"semver_match": ["^1.0.0", {"var": "semver"}]},
]
},
scope="global",
deliver="message_only",
)
bot.connect()
How label subscriptions work¶
When a form is submitted, the server:
- Creates a
Messagewithtype=FORM_SUBMISSIONand theanswer_labelas a label - Evaluates all registered label subscriptions against the message's labels
- Delivers the message to matching subscribers via
delivermode
The predicate uses JSON Logic to match against label fields:
{
"and": [
{"==": [{"var": "origin"}, "bot-poll"]}, # exact match on origin
{"regex_match": [{"var": "label"}, "^poll-"]}, # regex on label name
{"semver_match": ["^1.0.0", {"var": "semver"}]}, # semver range
]
}
var path |
Description |
|---|---|
origin |
Who created the label (e.g., "bot-poll") |
label |
The label name (e.g., "poll-response") |
semver |
Version string (e.g., "1.0.0") |
Deliver modes¶
| Mode | What the subscriber receives |
|---|---|
label_only |
LABEL_ASSIGNED event with label metadata |
message_only |
Full MESSAGE event with content and metadata |
both |
Both events |
For form submissions, use deliver="message_only" — you need the form data from the message metadata.
Multiple bots, one form¶
# Bot-A creates the form and thanks the user
class FormBot(BaseBot):
BOT_NAME = "formbot"
def _on_message(self, data):
if data.get("type") == "form_submission" and self._is_my_form(data):
group_id = data.get("group_id", "general")
self._send_text(group_id, "Thanks for filling out the form!")
# Bot-B archives all form responses
class ArchiveBot(BaseBot):
BOT_NAME = "archiver"
def _on_message(self, data):
if data.get("type") == "form_submission":
self.db.store(data)
# Bot-C reacts to everything from formbot
class ReactionBot(BaseBot):
BOT_NAME = "reactor"
def _on_message(self, data):
# Subtle acknowledgment
pass
# All three subscribe to the same label pattern
# Bot-A: label('formbot', 'my-form-resp', *)
# Bot-B: label(*, '*-resp', *)
# Bot-C: label('formbot', *, *)
Metadata structure¶
Form message (sent by bot)¶
metadata = {
"meadows": {
"form_handling": {
"answer_label": ["bot-poll", "poll-response", "1.0.0"], # required
"form": "<form>...</form>", # optional: HTML
"hosted-form": "https://example.com/form/quiz", # optional: URL
}
},
"custom_domain_key": "any domain metadata here" # preserved, not sanitized
}
Submission message (sent by frontend)¶
metadata = {
"meadows": {
"form_handling": {
"response": {"mood": "happy", "notes": "feeling great"}
}
}
}
Known meadows keys¶
The server sanitizes metadata['meadows'] — unknown keys are removed. This prevents protocol drift.
| Key | Type | Description |
|---|---|---|
form_handling |
dict |
Form content, answer_label, response data |
Domain metadata (keys other than meadows) is never touched.
The interactive-form label¶
The label ("meadows", "interactive-form", "1.0.0") is the protocol signal that tells frontends: "this message contains an interactive element."
- GUI (web): renders the HTML form from metadata
- TUI: shows the human-readable
contentwith a note: "[Interactive form - open in web interface]" - ntfy-client: can notify the user with a link to the web UI
The interactive-form label is not copied to the submission message. Only the answer_label is.
HTML sanitization¶
Form HTML is sanitized in the browser before rendering:
<script>tags are removedon*event handlers (onclick,onsubmit, etc.) are stripped- Form attributes (
name,value,type,placeholder,required) are preserved
This is a lightweight sanitizer — not DOMPurify. Forms come from trusted bots in this context.
Example: todo_bot¶
The todo_bot is a complete CRUD demo:
MEADOWS_JWT_TOKEN=$(cd meadows-server && uv run invoke bot-jwt --name=todo --expiry=1y) \
uv run python -m meadows.bot.examples.todo_bot
Commands:
- @todo add — shows a form to add a todo
- @todo list — shows todos with action buttons
- @todo edit <id> — opens an edit form
- @todo toggle <id> — toggles done/undone
- @todo delete <id> — deletes a todo
The todo_bot sends forms with answer_label=("bot-todo", "todo-add-response", "1.0.0") and subscribes to that label to receive submissions.
Architecture¶
meadows-protocol → MessageType.FORM_SUBMISSION, MessageKind.INTERACTIVE_FORM, EventName.FORM_SUBMISSION
meadows-server → on_form_submission handler, metadata sanitization, label evaluation
meadows-bot → send_form() convenience, auto-wrap, label subscription
meadows-web → label-based form detection, HTML sanitization, answer_label extraction
meadows-tui → interactive-form label detection, degrade message
See also¶
- Labeling system — label subscriptions, JSON Logic predicates
- Bot SDK — full bot-author surface including
send_form() - Socket.IO API —
form_submissionevent reference