tommy stands in for the services an application talks to but which are awkward to run locally — mail providers, SMS gateways, file transfer, chat webhooks, and more to come — and shows you exactly what your code sent. It answers the fake vendor APIs the way the real ones do, so official SDKs work against it unmodified, and it gives you a UI, a JSON API and an SSE stream to inspect every message as it arrives.
Point your application's mail, SMS, file-transfer or chat client at tommy instead of Mailjet, SendGrid, Twilio, a real SMTP/FTP/SFTP server or a Slack/ Teams webhook; nothing is ever delivered anywhere, and every message it captured is one API call or one browser tab away.
Nothing to build and no Go toolchain — one line is the whole of the setup:
docker run --rm -p 8811:8811 -p 8822:8822 can3p/tommy
That publishes the two ports everything HTTP needs: the UI and the API on
8811, the fake vendor ingress on 8822. Anything that speaks a protocol of its
own — SMTP, FTP, SFTP, TFTP, NFS, MLLP, SNMP traps — has a listener to publish
as well, and FTP additionally needs to be told an address it can hand back to
a client. docker-compose.yml is that stack already
wired up, and docs/docker.md is the reference for both:
every port, the /data volume, and how to narrow the image down to one
plugin with a mounted config.
A binary you installed or built yourself is the same server:
tommy serve
tommy is running
ui http://127.0.0.1:8811/ui/
api http://127.0.0.1:8811/api/v1
ingress http://127.0.0.1:8822
plugin mail ([mailjet resend sendgrid smtp])
plugin sms ([twilio])
plugin files ([ftp sftp tftp nfs])
plugin chat ([slack msteams])
plugin hl7 ([mllp])
plugin snmp ([trap])
plugin push ([fcm apns])
plugin s3 ([http])
plugin as2 ([http])
run `tommy providers` for copy-paste examples
In another terminal, send it something:
curl -s http://127.0.0.1:8822/v3.1/send \
-u "any-key:any-secret" -H 'Content-Type: application/json' -d '{
"Messages":[{"From":{"Email":"a@example.com","Name":"Alice"},"To":[{"Email":"b@example.com"}],
"Subject":"Hello from tommy","TextPart":"It works."}]}'
Then open http://127.0.0.1:8811/ui/ to see it in the inbox, or:
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=mail"
You do not have to go looking for it, though. Every response tommy sends names what it captured, so the link is already in your application's log:
curl -si http://127.0.0.1:8822/v3.1/send -u any:any \
-H 'Content-Type: application/json' -d '{"Messages":[{"From":{"Email":"a@example.com"},
"To":[{"Email":"b@example.com"}],"Subject":"Open me","TextPart":"It works."}]}' | grep -i x-tommy
# X-Tommy-Event-Url: http://127.0.0.1:8811/ui/events/01a07138320d00019f31cb1b
That URL is the mail on a page of its own — the rendered body, the headers, the
raw request. Every event the API returns carries the same link in its url
field, on /api/v1/events, on the SSE stream and on each plugin's own
read-back API, so a test that just sent something can print where to look at it.
tommy providers prints every enabled plugin and provider, its endpoints and
a ready-to-run snippet for each one, rendered against whatever ports your
configuration actually bound — useful before you've sent anything at all.
Every plugin and provider, what it is for, and where its documentation lives.
This page is an index only — one line each and a link. The authoritative
text is each component's own README.md, next to the code it describes, so
there is one copy of every claim rather than two that drift apart.
The runtime equivalent of this page is tommy providers, which prints every
provider's description, endpoints and runnable snippets rendered against the
ports the current configuration would actually bind. Use that when you want a
command to paste; use this when you want to know whether tommy covers the
thing you are trying to test.
Every component's README opens with the same three sections, per CLAUDE.md
rule 12: what it is, what it's for, and how to test it for real —
the last one being commands that have been run rather than commands that look
plausible.
Everything indexed here ships in one binary, and that binary ships as a
container image too: docs/docker.md.
api.resend.com. Send, batch and read-back.api.twilio.com can be pointed elsewhere.scp, sftp, a batch job that logs in.curl is useless.Three routes, and they all give you the same binary. The Docker image is the only one that needs nothing installed first.
docker run --rm -p 8811:8811 -p 8822:8822 can3p/tommy
Published on Docker Hub as can3p/tommy and on GHCR as
ghcr.io/can3p/tommy, multi-architecture, one static binary on a distroless
base. docs/docker.md covers publishing the protocol
listeners as well, the /data volume, and narrowing tommy to one plugin with a
mounted configuration.
go install github.com/can3p/tommy@latest
Download tommy and install into a local bin directory.
Latest version:
curl -L https://raw.githubusercontent.com/can3p/tommy/main/generated/install.sh | sh
Specific version:
curl -L https://raw.githubusercontent.com/can3p/tommy/main/generated/install.sh | sh -s 0.0.4
The script will install the binary into $HOME/bin folder by default, you can override this by setting
$CUSTOM_INSTALL environment variable
Get the archive that fits your system from the Releases page and
extract the binary into a folder that is mentioned in your $PATH variable.
Worth knowing before you look for a feature that is not here. Tommy captures what an application sent and answers with whatever the protocol requires so the client proceeds. It does not simulate scenarios, drive inbound traffic, or make policy decisions.
So a reply that is mechanical — derivable from the request — fits: an HL7
ACK, an AS2 MDN, Slack's ok, SMTP's 250. A reply that encodes a
decision somebody has to configure does not: approve or decline this card
payment, accept or reject this login. That is scenario machinery, and it is a
different tool.
Inbound traffic is out of scope in every form — webhooks and callbacks, Stripe
events, Twilio StatusCallback, SendGrid event webhooks, Slack interactivity,
asynchronous AS2 MDNs — because each needs outbound HTTP and a way to define
scenarios.
Individual providers refuse individual things for the same reason, and each
says so in its own README: FCM will not fake 404 UNREGISTERED, APNs will not
fake the errors that need delivery state, Slack's chat.update/chat.delete
are absent because events are immutable. Every one of those would require
inventing state tommy does not keep.
See docs/implementation-plan.md §2 for the full scoping rule and what is
planned next.