tommy

tommy

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.

Quickstart What it stands in for README API reference GitHub

30-second quickstart

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.

What tommy can stand in for

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.

mail Mail

Email you need to see rather than send — a rendered password reset, an attachment that must survive, a staging environment that must not reach real customers.
mailjet
Your code calls Mailjet's SDK or posts to its v3.1 endpoint.
resend
Your code sends through Resend's SDK or posts to api.resend.com. Send, batch and read-back.
sendgrid
Your code sends through SendGrid's SDK or v3 Mail Send.
smtp
Your code speaks SMTP directly rather than a vendor HTTP API. A real mail server on its own port.

sms SMS

Text messages, with the segment arithmetic that decides whether your one-time passcode costs one message or two.
twilio
Whatever normally calls api.twilio.com can be pointed elsewhere.

files Files

Anything your application writes over a file-transfer protocol you would rather not stand up for real.
ftp
The thing under test already speaks FTP — a legacy partner drop, a nightly export.
sftp
It carries an SSH client: scp, sftp, a batch job that logs in.
tftp
Network-device and PXE-style flows — firmware pushers, boot ROMs. UDP, no auth.
nfs
It mounts a filesystem instead of speaking a transfer protocol.

chat Chat

Webhook notifications to Slack or Teams, rendered as the card a channel would show, without spamming a real channel.
slack
Anything that talks to Slack — the SDK, a webhook URL, an alerting library.
msteams
The Teams webhook URL your pipeline or monitoring already posts to.

hl7 HL7

HL7 v2 clinical messages sent to a hospital interface engine you cannot get a test instance of.
mllp
HL7 over MLLP — framed TCP, so no HTTP client can drive it and curl is useless.

snmp SNMP

Your own agent's or device's outbound traps — whether the alert fires, and what varbinds it really carries.
trap
Checking a trap fires and says what you think it says. v1, v2c and informs.

push Push

Mobile push, answering the question most silent-push debugging comes down to: would this have displayed anything at all?
fcm
Your backend pushes through Firebase, and you want the targeting and payload shown without a device or project.
apns
Your backend pushes through Apple. HTTP/2 only — there is no HTTP/1.1 form.

s3 S3

An S3-compatible object store — bucket and object operations, kept in an inspectable, downloadable catalog instead of a cloud account.
http
Your app writes to S3 (or anything S3-compatible) through the AWS CLI or an AWS SDK, path-style.

as2 AS2

An EDI trading partner's AS2 endpoint, including the certificate exchange and the MDN receipt your integration blocks on.
http
AS2 over HTTP (RFC 4130), the transport binding a partner points a URL at.

Installation

Three routes, and they all give you the same binary. The Docker image is the only one that needs nothing installed first.

Docker

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

go install github.com/can3p/tommy@latest

Install Script

Download tommy and install into a local bin directory.

MacOS, Linux, WSL

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

Manual download

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.

What tommy deliberately will not do

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.

Documentation