meadows-server
MEADOWS coordination hub: the server-as-object. Socket.IO AsyncServer with an ASGI wrapper, an object-oriented Hub (no module globals), and a single chokepoint emit that validates frames against
meadows.protocolbefore they hit the wire.
Overview
The server handles:
- Socket.IO transport — real-time bidirectional messaging
- JWT authentication — user and bot identity verification
- Message persistence — append-only JSONL storage
- Group management — create, join, leave, delete groups
- Bot routing —
@botmention parsing and dispatch - Pattern matching — regex-based message interception for bots
- Label subscription evaluation — JSON Logic predicate matching against labels
- RPC routing — bot-to-bot service calls via label routing
- Rate limiting — 30 msg/min per bot with 60s cooldown
- Webhook API — HTTP endpoint for injecting messages
Install
Requires Python 3.14+.
uv pip install -e .
Run
uv run python -m meadows.server
# or
uv run uvicorn meadows.server.app:app --host 0.0.0.0 --port 8080
Configuration
| Env var | Default | Description |
|---|---|---|
MEADOWS_JWT_SECRET |
./shared_keys/jwt.key |
JWT secret. If the value is an existing file path, its bytes are used; otherwise the literal string. Must be 32+ bytes for HS256. |
MEADOWS_MESSAGES_DIR |
./messages |
Directory for JSONL message files. Created on startup if missing. Each <group_id>.jsonl file is a group. |
MEADOWS_CORS_ORIGINS |
* |
CORS allowed origins for Socket.IO. |
Architecture
MeadowServer (ASGI entrypoint)
-> AuthASGIApp (JWT middleware — gates /chat* and /r/* HTTP routes)
-> socketio.ASGIApp (Engine.IO/Socket.IO transport)
-> ChatNamespace (/chat namespace — all event handlers)
-> Hub (state: sessions, bots, groups, patterns, persistence)
Middleware stack
- MeadowServer (
app.py) — interceptsPOST /r/{group_id}for the webhook (handles its own JWT verification); everything else passes through. - AuthASGIApp (
auth.py) — HTTP-level JWT gate./socket.ioalways passes through (Engine.IO transport). Paths starting with/chatrequire a validAuthorization: Bearer <jwt>header. - socketio.ASGIApp — standard Socket.IO transport.
- ChatNamespace (
namespace.py) — the/chatnamespace handler. All application logic lives here.
The Hub (state container)
All mutable state lives on the Hub instance — never in module globals.
Infrastructure services (JSONLPersistence, NtfyPrefsStore) are wired
via endow — typed class attributes are
the source of truth, and with_injected(...) resolves dependencies.
| Attribute | Type | Purpose |
|---|---|---|
sio |
socketio.AsyncServer |
The Socket.IO server |
user_sessions |
dict[str, dict] |
Connected sessions (keyed by sid) |
bot_registry |
dict[str, dict] |
Registered bots (keyed by bot_name) |
groups |
dict[str, GroupState] |
Active groups (keyed by group_id) |
pattern_registry |
dict[str, list] |
Registered regex patterns (keyed by scope) |
label_subscriptions |
dict[str, list] |
Registered label subscriptions (keyed by scope) |
bot_rate_limits |
dict[str, list[float]] |
Per-bot sliding window timestamps for rate limiting |
rate_limited_bots |
dict[str, float] |
Per-bot cooldown expiry (monotonic) for rate limiting |
persistence |
JSONLPersistence |
Append-only JSONL message store |
ntfy_prefs |
NtfyPrefsStore |
Per-user ntfy notification preferences |
The chokepoint
Every client-bound frame passes through hub.emit_frame(), which runs
validate_frame() from meadows.protocol before the data hits the wire.
Invalid frames raise ValueError and are never emitted. This is the single
enforcement point for the protocol contract.
JWT authentication
All clients authenticate by emitting an authenticate event with a JWT token
on the /chat Socket.IO namespace. The server verifies the token and extracts
identity from the claims.
Token structure
Tokens are HS256 JWTs validated against meadows.protocol.jwt.JWTClaims:
{
"sub": "user-alice",
"role": "user",
"exp": 1735689600,
"iat": 1735686000,
"username": "alice",
"permissions": ["mention-all"]
}
| Claim | Required | Description |
|---|---|---|
sub |
yes | Stable identity. Must be prefixed: user-<name> or bot-<name>. |
role |
yes | "user" or "bot" |
exp |
yes | Expiry timestamp |
iat |
auto | Issued-at (auto-set by build_claims) |
username |
for users | Display name |
bot_name |
for bots | Bot display name (required when role=bot) |
permissions |
no | List of permission strings |
Minting tokens
Use build_claims() from meadows.protocol:
from meadows.protocol import build_claims, JWTRole
import jwt as pyjwt
claims = build_claims(name="alice", role=JWTRole.USER, permissions=["mention-all"])
token = pyjwt.encode(claims.model_dump(exclude_none=True), secret, algorithm="HS256")
Bots can also mint tokens for new users/bots via the request_user_jwt /
request_bot_jwt Socket.IO events (requires user-invite / bot-invite
permission).
Permissions
| Permission | Description |
|---|---|
user-invite |
Mint JWTs for new users |
bot-invite |
Mint JWTs for new bots |
mention-all |
Use @everyone / @all |
presence-read |
Read online status per group |
Socket.IO API
All application events are on the /chat namespace. The client connects,
emits authenticate with a JWT, then interacts via events.
Connection lifecycle
| Event | Direction | Description |
|---|---|---|
connect |
client -> server | Establishes WebSocket connection |
authenticate |
client -> server | JWT handshake. Server responds with authenticated (user) or bot_authenticated (bot), then sends group_list, bot_list, my_permissions, and auto-joins general. |
disconnect |
client -> server | Server cleans up session and leaves all rooms. If the client was a bot, it's removed from bot_registry, rate limit state is cleared, and bot_unregistered + bot_list are broadcast to all clients. |
Message types
Messages carry a type field that identifies their origin:
| Type | Description |
|---|---|
user |
Sent by a human user via Socket.IO |
bot |
Sent by a bot via bot_response event |
webhook |
Sent via the HTTP webhook endpoint |
reaction |
Emoji reaction on a message |
form_submission |
Interactive form submission (future) |
system |
System-generated message (future) |
Chat
| Event | Direction | Auth | Description |
|---|---|---|---|
message |
client -> server | yes | Send a message. Server broadcasts message to the group room, persists to JSONL, routes @bot mentions, and evaluates regex patterns. |
typing |
client -> server | yes | Typing indicator. Server broadcasts user_typing to the group (rate-limited to once per second). |
remove_message |
client -> server | yes | Mark a message as removed (strikethrough). Server broadcasts message_removed. |
fetch_messages |
client -> server | yes | Fetch specific messages by ID. Server responds with fetch_messages_result. |
bot_response |
client -> server | bot only | Bot sends a response. Server broadcasts message with type: "bot". |
Groups
| Event | Direction | Auth | Description |
|---|---|---|---|
create_group |
client -> server | yes | Create a group. group_id must match ^[a-z0-9_-]{1,32}$. All connected bots auto-join. |
list_groups |
client -> server | yes | Returns group_list with all groups. |
join_group |
client -> server | yes | Join a group. Server sends joined_group with display history and broadcasts members_updated. |
leave_group |
client -> server | yes | Leave a group. Server broadcasts members_updated and user_left. |
delete_group |
client -> server | yes | Delete a group (cannot delete general). Archives the JSONL file. |
Reactions
| Event | Direction | Auth | Description |
|---|---|---|---|
add_reaction |
client -> server | yes | Toggle a reaction (emoji) on a message. If the same reaction exists, it's removed (toggle). |
remove_reaction |
client -> server | yes | Explicitly remove a reaction. |
Patterns (bot feature)
| Event | Direction | Auth | Description |
|---|---|---|---|
register_pattern |
client -> server | bot only | Register a regex pattern. Server evaluates all patterns on every incoming message and emits pattern_matched to the registering bot. Max 50 patterns per scope (room or global), 512 chars. |
unregister_pattern |
client -> server | bot only | Remove a pattern by name. |
Label subscriptions
| Event | Direction | Auth | Description |
|---|---|---|---|
register_label_subscription |
client -> server | yes | Register a label subscription with a JSON Logic predicate. Server evaluates subscriptions against message labels and emits label_assigned to matching subscribers. |
unregister_label_subscription |
client -> server | yes | Remove a label subscription by name. |
Bot registration
| Event | Direction | Auth | Description |
|---|---|---|---|
register_bot |
client -> server | bot only | Register bot metadata (description, commands, context_limit). Identity comes from JWT, not payload. Server broadcasts bot_list to all connected clients after registration. |
bot_list_bots |
client -> server | yes | Returns bot_list with all registered bots. |
Rate limiting (bot messages)
| Limit | Value | Scope |
|---|---|---|
| Max messages per window | 30 | per bot (sliding 60s window) |
| Cooldown on violation | 60 seconds | per bot |
| Max patterns per scope | 50 | per scope-key (room or global) |
| Max pattern length | 512 chars | — |
When a bot exceeds 30 messages in 60 seconds, the server emits
rate_limited to the bot and skips broadcasting the message. The bot
enters a 60-second cooldown during which all bot_response events are
rejected. Rate limit state is cleared when the bot disconnects.
Message envelope
All messages (user, bot, webhook, reaction) share the same envelope from
meadows.protocol:
{
"id": "01923a4f5e6c-3a2f4b8c0d1e",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"type": "user",
"user_id": "user-alice",
"username": "alice",
"group_id": "general",
"content": "Hello world",
"timestamp": "2026-07-07T12:00:00.000000",
"is_everyone": false,
"removed": false
}
| Field | Type | Description |
|---|---|---|
id |
string | Sortable message ID (<13-digit-ms>-<12-hex>) |
uuid |
string | UUID4 |
type |
string | user, bot, webhook, reaction, form_submission, system |
user_id |
string | Stable identity from JWT sub (e.g. user-alice, bot-echo) |
username |
string | Display name (users only) |
bot_name |
string | Bot display name (bots only) |
group_id |
string | Group this message belongs to |
content |
string | Message body (markdown) |
timestamp |
string | ISO 8601 timestamp |
is_everyone |
bool | true if @everyone/@all was used with mention-all permission |
removed |
bool | true if message was soft-deleted |
quoted_message |
object | Reply context (if replying to another message) |
emoji |
string | Emoji (reaction messages only) |
target_message_id |
string | Target message (reaction messages only) |
original_command |
string | Original @bot command (bot responses only) |
JWT invite
| Event | Direction | Auth | Description |
|---|---|---|---|
request_user_jwt |
client -> server | user-invite |
Mint a JWT for a new user. Responds with user_jwt_generated. |
request_bot_jwt |
client -> server | bot-invite |
Mint a JWT for a new bot. Responds with bot_jwt_generated. |
ntfy preferences
| Event | Direction | Auth | Description |
|---|---|---|---|
get_ntfy_prefs |
client -> server | yes | Returns ntfy_prefs with the user's notification settings. |
save_ntfy_prefs |
client -> server | yes | Save notification settings. |
Server-to-client events (emitted by server)
| Event | Trigger |
|---|---|
authenticated |
User auth success (includes groups, bots, permissions) |
bot_authenticated |
Bot auth success |
auth_error |
Auth failure |
message |
Message broadcast (user, bot, or webhook) |
message_removed |
Message marked as removed |
user_typing |
Typing indicator |
joined_group |
Group joined (includes display history) |
left_group |
Group left |
group_list |
Full list of groups |
group_created |
New group created |
group_deleted |
Group deleted |
members_updated |
Group membership changed |
user_joined |
User joined a group |
user_left |
User left a group |
bot_list |
List of registered bots |
bot_registered |
Bot registration confirmed |
bot_command |
@bot mention routed to a bot |
bot_jwt_generated |
Bot JWT minted |
user_jwt_generated |
User JWT minted |
my_permissions |
User's permission list |
reaction_added |
New reaction on a message |
reaction_toggled |
Reaction toggled off |
reaction_removed_event |
Reaction explicitly removed |
pattern_registered |
Pattern registration confirmed |
pattern_unregistered |
Pattern removed |
pattern_matched |
Regex pattern matched a message |
label_subscription_registered |
Label subscription confirmed |
label_subscription_unregistered |
Label subscription removed |
label_assigned |
Label subscription matched — delivered to subscriber |
bot_unregistered |
Bot disconnected (broadcast to all connected clients) |
bot_not_found |
@mention targets neither a registered bot nor a known user in the group |
rate_limited |
Bot exceeded rate limit (30 msg/min), enters 60s cooldown |
ntfy_prefs |
ntfy preferences returned |
ntfy_prefs_saved |
ntfy preferences saved |
error |
Generic error |
Webhook API
In addition to Socket.IO, the server exposes an HTTP endpoint for injecting messages without a persistent WebSocket connection.
POST /r/{group_id}
Send a message to a group over HTTP. The message goes through the same pipeline as Socket.IO messages: broadcast, persistence, @bot routing, and regex pattern evaluation.
Request:
POST /r/general
Authorization: Bearer <jwt>
Content-Type: application/json
{"content": "Build passed"}
Auth: Any valid JWT (user or bot). No specific permission required.
Body:
| Field | Type | Required | Description |
|---|---|---|---|
content |
string | yes | Message content (markdown, max 100k chars) |
Response:
{"status": "ok", "message_id": "01923a4f5e6c-3a2f4b8c0d1e"}
Error responses:
| Status | Body | Condition |
|---|---|---|
| 401 | {"error": "missing bearer token"} |
No Authorization header |
| 401 | {"error": "invalid token"} |
Bad or expired JWT |
| 404 | {"error": "group not found"} |
Unknown group_id |
| 400 | {"error": "invalid JSON body"} |
Malformed request body |
| 400 | {"error": "content is required"} |
Empty or missing content |
| 400 | {"error": "content too large"} |
Content exceeds 100k chars |
Behaviour:
- Messages are typed as
webhook(distinct fromuser/bot) @botname commandin content triggers bot routing@everyone/@allsetsis_everyone=trueif the JWT hasmention-all- Registered regex patterns are evaluated against the message content
- Sender identity is derived from the JWT (not the request body) to prevent spoofing
cURL example:
curl -X POST http://localhost:8080/r/general \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"content":"Deploy #42 complete"}'
Python example:
import httpx
resp = httpx.post(
"http://localhost:8080/r/general",
headers={"Authorization": f"Bearer {jwt_token}"},
json={"content": "Build passed"},
)
print(resp.json()) # {"status": "ok", "message_id": "..."}
Groups model
Groups are the routing substrate: messages, reactions, mentions, and patterns all scope to a group.
- Discovery: On startup,
hub.start()scansMEADOWS_MESSAGES_DIRfor*.jsonlfiles. Each file is a group.generalis always seeded. - Creation:
create_groupevent. Validatesgroup_idagainst^[a-z0-9_-]{1,32}$. All connected bots auto-join. - Deletion:
delete_groupevent (cannot deletegeneral). Renames the JSONL file to.jsonl.deleted(audit trail). - Membership: Tracked in-memory on
GroupState.members(keyed byuser_idfrom JWTsub, not by Socket.IO sid).
Persistence
Messages are stored as append-only JSONL files — one file per group at
<messages_dir>/<group_id>.jsonl. Each line is a JSON-serialized Message
envelope from meadows.protocol.
- Append: New messages are written to the end of the file.
- Removal:
mark_removed()rewrites the file withremoved: trueon the target message. Data is never deleted. - Display history: All messages (including removed) for the chat UI.
- Thread context: Last N non-removed messages for bot context (default 30).
Message pipeline
When a message arrives (via Socket.IO message event or HTTP webhook), the
server runs _dispatch_message():
- Broadcast — emit
messageto the group room via the chokepoint - Persist — append to the group's JSONL file
- @bot routing — parse
@botname command argsfrom content, emitbot_commandto the named bot with thread context. If the name matches a known user in the group, it's silently treated as a user mention. If it matches neither a bot nor a user,bot_not_foundis emitted to the sender. - Pattern evaluation — run all registered regex patterns against the
content, emit
pattern_matchedto registering bots
Package contents
| File | Purpose |
|---|---|
hub.py |
Hub: the server-as-object. State container with explicit start()/stop() lifecycle. |
chokepoint.py |
validate_frame() / emit_frame(): the single client edge. Validates every frame against meadows.protocol. |
auth.py |
verify_token() + AuthASGIApp: JWT verification and HTTP middleware. |
namespace.py |
ChatNamespace: the Socket.IO /chat namespace handler. All event handlers. |
persistence.py |
JSONLPersistence (endow Service): append-only JSONL message store. |
groups.py |
GroupState: in-memory group membership. |
ntfy_prefs.py |
NtfyPrefsStore (endow Service): per-user notification preferences. |
app.py |
MeadowServer / create_app(): ASGI entrypoint composing Hub + auth + webhook routing. |
Architecture invariants
- Hub is an object — no module-level state. Someone can instantiate
Hub(), wrap it, run it in another process. - Single chokepoint emit — one path through which all client-bound
frames pass, validating against
meadows.protocolfirst. - Protocol is the only sibling dependency — imports from
meadows.protocolonly, never frommeadows.clientormeadows.bot. - PEP 420 namespace —
src/meadows/server/__init__.pyis fine; there is nosrc/meadows/__init__.pyanywhere.
Commit conventions
This project uses Conventional Commits. Each commit message describes one atomic change:
<type>(<scope>): <short summary>
<body> # optional
Types: feat, fix, docs, test, refactor, chore, ci
Scopes: server, protocol, client, bot, web, tui, tests
Examples:
feat(server): add inbound webhook endpoint POST /r/{group_id}
feat(server): enforce bot rate limiting (30 msg/min, 60s cooldown)
fix(server): enforce POST-only on webhook endpoint
docs: expand README with Socket.IO API reference
test(server): add webhook endpoint tests (29 tests)
refactor(server): extract _check_rate_limit helper
Test
uv run pytest -q
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file meadows_server-0.1.0.tar.gz.
File metadata
- Download URL: meadows_server-0.1.0.tar.gz
- Upload date:
- Size: 114.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.15 {"installer":{"name":"uv","version":"0.9.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.3","id":"zena","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b35d4b244529314a75e2412fa872115bc40109db10989f4d53d111987605ef5
|
|
| MD5 |
af2488421224ffa8049f8523ed3b8002
|
|
| BLAKE2b-256 |
70f0b132cbe4e99de3ba3eaee08b386801e47648b1a191fc8dc3841a861a9b18
|
File details
Details for the file meadows_server-0.1.0-py3-none-any.whl.
File metadata
- Download URL: meadows_server-0.1.0-py3-none-any.whl
- Upload date:
- Size: 39.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.15 {"installer":{"name":"uv","version":"0.9.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.3","id":"zena","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
12dd4cb7bb31bedbef5859fa71fec678648e78c0875ba39e3a3e7ac8e01f7336
|
|
| MD5 |
75d879bcc4b48eb3a38496675412d121
|
|
| BLAKE2b-256 |
85975baea95a9545af7f42acd9a5eba36b7f5ab5951ecb64ca9f08f259a165a0
|