A lightweight Python tool for sending a single Markdown-formatted notification (subject, body, mentions, attachments) to Cisco Webex, Microsoft Teams, and Slack at once.
This is a Python port of chatxgo, preserving the same CLI flags, config file format, and Markdown behavior per chat tool.
chatxpy can be used in two ways:
- As a standalone CLI tool — run the
chatxpybinary (built with PyInstaller) directly from a shell script, CI job, cron task, etc. - As a Python library (
chatxpy.notify) — import thenotifypackage into your own Python program and call it directly, without going through the CLI or a config file at all.
Every chat tool is optional and independent: giving a tool's destination (*_DST) enables it, leaving it empty disables it, and a single chatxpy invocation delivers the same message to every tool that is enabled at once. A delivery failure on one tool does not stop delivery to the others.
Copy config.ini.example to config.ini and fill in the destinations/credentials for the chat tools you want to use. A tool is enabled simply by giving it a *_DST value; leave it empty to disable that tool.
WEBEX_TOKEN=... # Webex bot/personal access token
WEBEX_DST=... # destination roomId
MSTEAMS_DST=... # Teams incoming webhook URL
SLACK_DST=... # Slack incoming webhook URL
SLACK_TOKEN=... # optional, needed only to upload local file attachments
SLACK_CHANNEL=... # optional, channel for uploaded attachments
config.ini contains live credentials, so it is listed in .gitignore — never commit it. Commit config.ini.example instead.
When run as the CLI, chatxpy resolves config.ini in this order:
- A
config.iniin the current directory takes priority. - Otherwise, a per-user location is used:
- Linux/macOS:
~/.config/chatxpy/config.ini - Windows:
%AppData%\chatxpy\config.ini
- Linux/macOS:
Pass -config /path/to/config.ini to use a specific file instead of the resolved default. A missing config file is not an error — it simply leaves every chat tool disabled.
config.ini can hold several named profiles as INI sections, so you can keep, for example, a personal and a work destination side by side in one file:
[default]
SLACK_DST=https://hooks.slack.example/default
[work]
SLACK_DST=https://hooks.slack.example/work
Select which profile to use with -profile/-p (default: default). Settings written before any [section] header belong to the default profile. Requesting a profile that doesn't exist in the file is an error.
chatxpy -subject "Deploy done" -body "**v1.2.3** shipped" -mention U0123456 -attach ./report.pdf
| Option | Shorthand | Description |
|---|---|---|
-subject |
-s |
Message subject/title |
-body |
-b |
Message body, formatted as Markdown |
-mention |
-m |
User to mention, as id or id:label (repeatable, or comma-separated). id is the native identifier for each tool: a Slack user ID, a Webex email/personId, or for Teams a Microsoft Entra object ID or user principal name/email. |
-attach |
-a |
File path or URL to attach (repeatable, or comma-separated) |
-config |
Path to the config.ini file (default: resolved as described in Setup) |
|
-profile |
-p |
Profile (config.ini section) to use (default: default) |
-debug |
Print verbose debug output | |
-u |
-update |
Self-update to the latest GitHub release |
-v |
-version |
Show version information |
-h |
-help |
Show usage information |
A mention can be given as id:label, separating the identifier and the display name with a colon:
./dist/chatxpy -profile "work" -subject "Deploy done" -body "**v1.2.3** shipped" -mention "jane.doe@example.com:Jane"Run chatxpy -update (or chatxpy -u) to check GitHub for a newer release of chatxpy and replace the currently running binary in place. This only replaces a PyInstaller-built binary from task build; it is a no-op concept when running from source via python -m chatxpy.
chatxpy -updateIf the installed binary is already the latest version, chatxpy reports that and exits without changing anything.
- Messages are sent to Teams as Adaptive Cards rather than the legacy MessageCard format. An Adaptive Card
TextBlocksupports a subset of CommonMark Markdown (bold, italic, bullet lists, numbered lists, links); headings, tables, images, and code blocks are not supported. - Incoming Webhooks that use Adaptive Cards officially support mentioning a user by either their Microsoft Entra object ID (a GUID) or their user principal name (UPN, typically their email address).
Build a notify.Config directly in code; a tool is enabled by setting its dest.
from chatxpy import notify
cfg = notify.Config(slack=notify.SlackConfig(dest="https://hooks.slack.example/..."))
# or load a specific profile (section) from a config.ini file:
# cfg = notify.configfile.load_config_file("/path/to/config.ini", "work")
dispatcher = notify.Dispatcher(cfg)
results = dispatcher.send(
notify.Message(
subject="Deploy done",
body="**v1.2.3** shipped",
mentions=[notify.Mention(id="U0123456")],
attachments=["./report.pdf"],
)
)Dispatcher.send returns one notify.Result(tool, error) per enabled tool; it raises notify.NoRecipientsError if no tool is enabled at all.
The -body text is passed through to each tool largely as-is, but every chat tool renders its own dialect of Markdown, and none of them support the full CommonMark syntax. Write -body for the tool(s) you actually send to, and check the table below before relying on a given syntax.
| Feature | Cisco Webex | Microsoft Teams (Adaptive Cards) | Slack (mrkdwn) |
|---|---|---|---|
| Bold | **bold** |
**bold** |
*bold* — CommonMark's **bold** is not supported and shows literally |
| Italic | _italic_ |
_italic_ |
_italic_ |
| Strikethrough | Not supported | Not supported | ~strike~ |
Headings (#, ##, ...) |
#/##/### (h1–h3 only) |
Not supported | Not supported |
Blockquote (>) |
Supported | Not supported | Supported |
| Unordered list | * item |
- item |
No native list syntax — write each line manually (e.g. • item) |
| Ordered list | 1. item |
1. item |
No native list syntax — write each line manually |
| Links | [text](url) |
[text](url) |
Not [text](url) — must be <url + | + text>, or a bare URL |
| Inline code / code block | Supported | Not supported | Supported |
Horizontal rule (---) |
Supported | Not supported | Not supported |
| Tables | Not supported | Not supported | Not supported |
| Images | Not supported inline via Markdown | Not supported | Not supported inline (requires Slack Block Kit, not used by chatxpy) |
Notable pitfalls:
- Slack does not speak CommonMark. Slack's
mrkdwndialect uses single asterisks for bold and<url|text>for links, not double asterisks or[text](url). - Tables are unsupported everywhere — Webex, Teams, and Slack all lack table rendering in the message formats chatxpy uses.
- Teams (Adaptive Cards) has the narrowest subset: only bold, italic, lists, and links render; headings, blockquotes, code blocks, horizontal rules, and tables are all shown as plain/literal text or dropped.
pip install chatxpy
# or, without installing it into your environment:
uvx chatxpy -subject "Deploy done" -body "**v1.2.3** shipped"This installs the chatxpy console script plus the chatxpy/chatxpy.notify library. See Releasing to PyPI below for how new versions are published.
A standalone PyInstaller binary (no Python interpreter required) is also available from GitHub Releases; see task build below to build one yourself.
Requires Python 3.13+ and uv.
task sync # install dependencies
task build # build a standalone binary with PyInstaller for the current platform
task test # run the test suite
task run # run from source (pass flags with: task run -- -subject hi -body world)
task debug # run from source with -debug
PyInstaller does not cross-compile: task build only produces a binary for the platform it runs on. Build on each target OS (e.g. via a CI matrix) to release for multiple platforms.
- Bump
versioninpyproject.toml(uv's build backend does not derive it from git tags) and commit. - Tag the release:
git tag vX.Y.Z && git push origin vX.Y.Z. task dist— builds the sdist and wheel intodist/.task dist:check— validates the package metadata/long description withtwine check.task publish:test— uploads to TestPyPI first, to dry-run the release.task publish— uploads to PyPI. This is irreversible: a given version number can never be re-uploaded, even if deleted.
task publish/task publish:test read credentials from UV_PUBLISH_TOKEN (a PyPI/TestPyPI API token), or use PyPI's trusted publishing from CI with no token at all.
