The Python library for writing a bridge between Muster and a messenger it does
not support natively: the library serves Muster's bridge protocol (api/bridge.yaml) as an asynchronous ASGI
application — tokens, validation, idempotency, events and signed push — and the bridge author writes only the calls to
their messenger. MIT licensed.
A bridge is a small HTTP service that Muster calls to post, edit and reply to messages, and that reports what people do
in the messenger — button presses, messages in Threads, Account link tokens — as events. Why the protocol exists and
how it is versioned is Muster's
ADR-0017;
the protocol itself is api/bridge.yaml, of which
this repository keeps a byte-for-byte copy.
pip install muster-bridgePython 3.11 or newer. The distribution is muster-bridge, the import package muster_bridge; it depends on
Starlette, httpx and pydantic 2 only. Versions stay 0.x until Muster 1.0. Every 0.x release speaks protocol
version 1; a breaking protocol change would be a new protocol version, not a new library version.
examples/memory_bridge.py is a whole bridge in one file: its messenger lives in memory
and writes every message the bot posts to the log. Run it with a token, the one a Bridge Connection in Muster would
hold. In a clone, with uv:
printf example-token > /tmp/bt
uv run python examples/memory_bridge.py --token-file /tmp/btor with the package installed beside uvicorn (pip install muster-bridge uvicorn), as
python examples/memory_bridge.py --token-file /tmp/bt.
It listens on 127.0.0.1:18095 (--listen), knows the channels alerts and ops (--channels) and the people
alice and bob (--users), and offers every feature of the protocol, or only those that BRIDGE_FEATURES lists:
buttons, thread_messages, direct_messages (which also turns on user_lookup login), private_answers,
native_mentions, quiet_messages, status_color, deletion_events and delete. With --push-url and
--push-secret-file it pushes its events as well. In another terminal:
curl -s -o /dev/null -w '%{http_code}\n' 127.0.0.1:18095/v1/info # 401: no token
curl -s -H 'Authorization: Bearer example-token' 127.0.0.1:18095/v1/info \
| jq -c '{protocol_version, user_lookup}' # {"protocol_version":"1","user_lookup":"login"}Then let Muster's conformance check run every step of the protocol against it, from Muster's development image. The
check reaches the example on the host's loopback, which --network host gives only on Linux:
docker run --rm --network host -v /tmp/bt:/token:ro ghcr.io/muster-io/muster:latest bridge check \
http://127.0.0.1:18095 --token-file /token --channel alerts --forbidden-channel nobot --handle alice \
--no-interactivePASS info
PASS channel_check
PASS post
PASS replayed_key
PASS buttons
PASS edit
PASS thread_reply
PASS thread_ref
PASS forbidden_channel
PASS user_lookup
PASS direct_message
PASS delete
PASS poll
SKIP press: --no-interactive
SKIP push: --no-interactive
PASS answer
The heart of the example is a Config, a messenger and the application, mounted in a Starlette application beside a
route of its own and served by uvicorn:
config = Config(
name="muster-bridge-python memory example",
version=muster_bridge.__version__,
messenger_name="Memory example",
bots=[Bot(name="main", token=token)],
limits=Limits(max_body_length=4000, edit_max_age_seconds=None),
capabilities=Capabilities(
buttons=CapabilitiesButtons(
max_per_message=5,
max_per_row=5,
data_max_bytes=64,
styles=[ButtonStyle.primary, ButtonStyle.success, ButtonStyle.neutral, ButtonStyle.danger],
),
thread_messages=CapabilitiesThreadMessages.all,
direct_messages=CapabilitiesDirectMessages(bot_can_start=True),
delete=CapabilitiesDelete(max_age_seconds=None),
),
user_lookup="login",
)
bridge = BridgeApp(config, chat) # chat implements Messenger, UserFinder, DirectMessenger and Deleter
app = Starlette(
routes=[
# Outside the protocol: press the first button of a message as a person, for scripts.
Route("/demo/press", demo_press, methods=["POST"]),
Mount("/", app=bridge),
],
lifespan=bridge.lifespan,
)
uvicorn.run(app, host="127.0.0.1", port=18095)POST /demo/press {"message_id": "4f0c1a-m2", "user": "alice"} presses the first button of message 4f0c1a-m2 as
alice, so that a script can play the person of an interactive check. It has no authentication of its own: keep it
on loopback. Message ids start with a prefix that is random for each start of the example, so that a restarted example
never reuses an id that Muster still holds.
BridgeApp is an ASGI application: serve it with uvicorn or another ASGI server, on its own or mounted under a path of
a Starlette or FastAPI application; the base URL of the Bridge Connection in Muster then ends with that path. Starlette
runs the lifespan of the top-level application only, so a host that mounts the bridge passes the bridge's on, which
stops the pushers on shutdown:
bridge = BridgeApp(config, messenger)
app = FastAPI(lifespan=bridge.lifespan)
app.mount("/bridge", bridge)A host with a lifespan of its own enters the bridge's in it:
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with bridge.lifespan(app):
yieldA BridgeApp has one lifespan: its end stops the pushers for good. A program or a test that starts the host again,
such as a second with TestClient(app), makes a new BridgeApp. Without a lifespan, as with TestClient(app) without
with, the bridge runs on each request's event loop in turn, and a pusher starts on the next event.
pollEvents holds a request for up to 60 seconds. uvicorn sets no timeout that cuts such a request short; a proxy in
front of the bridge needs a read timeout above 75 seconds, or it cuts a poll off without an answer, so that its events
reach Muster late.
- Implement the protocol
Messenger: the coroutinespost_message,edit_message,reply_in_thread,check_channelandanswer_event, each given the name of the bot whose token Muster sent. Define them withasync def; a method wrapped by a decorator that returns a coroutine is taken once the wrapper is marked withinspect.markcoroutinefunction(Python 3.12 or newer). For the features you declare, alsoUserFinder(find_user, for auser_lookupother thannone),DirectMessenger(send_direct_message, forcapabilities.direct_messages) andDeleter(delete_message, forcapabilities.delete).BridgeAppraisesConfigErrorfor a configuration whose features lack their methods, or whoseinfowould not match the protocol, naming every reason. The library validates every request against the models generated from the protocol before it calls a method, and every answer before it sends it. - Answer the messenger's failures by raising
Transient,RateLimited(with the waitretry_afterin seconds, which it requires because Muster waits exactly that long, andscopechannelorbot),Gone(withedit_window_closed=Truefor an edit the messenger refuses only for the message's age),ForbiddenorInvalid, each with adetailthat Muster shows the Admin: never put a secret in it. Each becomes its problem, withRetry-AfterforRateLimited; a class the operation does not declare is mapped —Gonefrompost_message,send_direct_messageandcheck_channelandInvalidfromcheck_channelanswerforbidden, any othertransient— and logged. Any other exception is answered astransientwith a generic detail that carries none of its text, and logged without the token. - Serve the application as Serving the bridge above says.
- Report what the messenger tells you, from the event loop that serves the bridge:
await bridge.press(...),thread_message(...),message_deleted(...)anddirect_message(...). Each queues the event and returns at once, so you confirm a press to your messenger within its deadline whatever Muster does; give the messenger's own update id asupdate_id, so that an update it delivers twice is queued once, and inchatwhatever you need to answer the person, whichanswer_eventgets back inReportedEvent.chatand Muster never sees. A direct message that is an Account link token becomes anaccount_linkevent; any other direct message is ignored. A report raisesUnknownBotError,NotOfferedErrorfor a feature the bridge does not offer andInvalidEventErrorfor an event that would not match the protocol. From another thread, hand the coroutine to the loop withasyncio.run_coroutine_threadsafe. - Turn Mention markers into native mentions or plain names with
replace_mentions(body, mentions, render). - For pushes, give a bot a
PushTarget— the callback URL and the push secret that its Bridge Connection in Muster shows — inBot(push=...)or later withbridge.set_push(bot, target), from the thread of the event loop that serves the bridge (from another thread,loop.call_soon_threadsafe(bridge.set_push, bot, target)). The pushers are tasks of the running event loop that post each event, signed as Standard Webhooks specifies, one at a time and in order, retrying with back-off for at least an hour;aclose()or the end oflifespanstops them for good. Events stay queued for polling either way. MemoryStore, the defaultStore, forgets Idempotency-Keys and queued events on a restart, and holds a day of events and answers in memory; implement the protocolStoreover your database to keep them. Long polls and pushes are woken in the process that queued an event, so with several processes over one store, run each bot in one process only.
A few more things to know:
Personand the other models are pydantic models that validate on construction: cut a display name to 200 characters before you build thePersonof a report.Limitsneedsedit_max_age_seconds,Nonewhen edits never expire, andCapabilitiesDeleteneedsmax_age_seconds,Nonefor no limit.- A repeated
Idempotency-Keyunder the same token answers the first result without calling the messenger again. A messenger with an idempotency mechanism of its own can be given the key:idempotency_key(), called insidepost_message,reply_in_thread,send_direct_messageoranswer_event, returns it, andNoneelsewhere. - The library logs through
Config.logger, by default the loggermuster_bridge. Its own messages never carry a token, a push secret or an Account link token, and it replaces the bots' tokens and push secrets in the text of every exception it logs; keep anything else secret, such as an event's Account link token, out of the exceptions of your messenger and your store.
muster bridge check <url> --token-file <file> --channel <id> runs every step of the protocol against a bridge and
prints PASS, FAIL or SKIP for each; --forbidden-channel and --handle enable the steps that need them. Without
--no-interactive it also waits for a person to press a button (press) and, for a bridge that pushes, receives the
push (push): it prints PUSH <url> once its listener (--push-listen) is up — point the bridge's push target there,
with the secret of --push-secret-file — and WAIT press: …, message_id <id> before it waits. make conformance
plays that person through the example's POST /demo/press; do the same in your bridge's tests with your messenger's
own means.
api/bridge.yaml is a copy of Muster's, never edited here:
src/muster_bridge/models.py, the pydantic models the library validates requests with
and builds answers from, is generated from it by datamodel-code-generator. make spec-check compares it with the one on
Muster's master byte for byte, every day in CI; make spec-sync takes the new one and regenerates the models.
Development needs uv 0.13 or newer, which installs the Python of .python-version and
the tools locked in uv.lock, GNU make and curl; make conformance also needs Linux with Docker. The Makefile is the
only entry point; make help lists the targets.
| Target | Does |
|---|---|
make fmt |
formats the code and sorts the imports (ruff) |
make lint |
checks the licence headers, uv.lock, and the code with ruff (lint and format) and mypy in strict mode |
make test |
runs the tests with the coverage gate of 90 % of the package (models.py excluded) |
make generate, make generate-check |
generates src/muster_bridge/models.py from api/bridge.yaml; fails when it is stale |
make spec-check, make spec-sync |
compares api/bridge.yaml with Muster's; replaces it and regenerates |
make licenses |
fails on a runtime dependency outside MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, PSF-2.0 and MPL-2.0 |
make build |
builds the sdist and the wheel into dist/ |
make ci |
lint, generate-check, spec-check, licenses, test and build, as a pull request runs them |
make conformance |
installs the built wheel and uvicorn into a fresh virtual environment, runs the example from it with python -I, and runs muster bridge check from ghcr.io/muster-io/muster:edge (MUSTER_IMAGE) against it three times: every feature, only thread_messages, and interactively with push |
make clean |
removes bin/, dist/, .venv/, the caches and the coverage data |
CONTRIBUTING.md says how to contribute, AGENTS.md is the guide for everyone who changes the code, and SECURITY.md how to report a vulnerability privately. The library is under the MIT licence; contributions are accepted under it, without a CLA.