Messaging channels
Slack, Teams, Telegram and WhatsApp bots, and a Voiceflow agent, all over the REST target with asynchronous replies.
Each recipe is a whole project you can copy, change the URLs and run; see Recipes for the other pages.
Unless a recipe shows its own, they run with this project:
# tamesu.yaml
version: 1
suites: ["*.yaml"]
genkit: { plugins: [googlegenai] }
models: { default: googleai/gemini-2.5-flash }
secrets:
GEMINI_API_KEY: env:GEMINI_API_KEY
A Slack bot, as a user in the workspace
Slack lets a program act as a person: with a user token (xoxp-, scopes chat:write,
channels:history or groups:history, im:write, im:history) tamesu posts to a channel and
reads the thread back. Each test is its own thread: the turn’s request posts the message,
request.capture keeps its timestamp, and the poll reads the replies under it. The bot’s
messages are the ones with a bot_id, so the JSONPath filter leaves the user’s own message out.
suite: slack-bot
tags: [slack, nightly]
vars: { channel: C0123456789 }
secrets: { SLACK_USER_TOKEN: env:SLACK_USER_TOKEN }
targets:
slack:
type: rest
url: https://slack.com/api/chat.postMessage
headers: { Authorization: "Bearer {{secrets.SLACK_USER_TOKEN}}" }
request:
body: { channel: "{{vars.channel}}", text: "{{message}}", thread_ts: "{{session.thread}}" }
capture: { ts: $.ts } # the message just posted; the bot answers after it
response:
text: "$.messages[?(@.bot_id && @.ts != '{{sent.ts}}')].text"
poll:
url: https://slack.com/api/conversations.replies?channel={{vars.channel}}&ts={{session.thread}}&oldest={{sent.ts}}
headers: { Authorization: "Bearer {{secrets.SLACK_USER_TOKEN}}" }
interval: 1s
timeout: 30s
idle: 3s # the bot may answer in two messages
session:
open: # the thread's root message, posted once
url: https://slack.com/api/chat.postMessage
headers: { Authorization: "Bearer {{secrets.SLACK_USER_TOKEN}}" }
body: { channel: "{{vars.channel}}", text: "tamesu test {{session}}" }
capture: { thread: $.ts }
tests:
- name: order status
target: slack
persona: { name: Marta, style: "polite, direct" }
scenario: { goal: "Ask where order ORD-1001 is and when it arrives", max_turns: 4 }
assert:
- contains: "ORD-1001"
- judge: "The agent gives a delivery estimate or says how to get one"
- json_path: { path: "$.messages[?(@.bot_id)].blocks" } # the answer uses Block Kit
- metric: { name: turn_latency_ms_p95, lt: 8000 }
Without a cursor the poll returns the whole thread every time, so a poll replaces the one before
it and the turn keeps the latest state; oldest keeps earlier turns out. Slack’s history scopes
need the app installed in the workspace, and the token belongs to the test user, so keep it in a
secret and the channel private to the team.
A Teams or Copilot Studio bot, through Direct Line
Bot Framework bots, including the ones Teams and Copilot Studio run, expose Direct Line: a channel made for custom clients, enabled in the bot’s Azure channels with a secret. One request starts the conversation, each turn posts an activity, and the replies are read with a watermark so every poll returns only what is new. The test exercises the bot’s logic end to end; Teams-only cards and channel data render differently in Teams itself.
suite: teams-bot
tags: [teams]
vars: { dl: https://directline.botframework.com/v3/directline }
secrets: { DIRECT_LINE_SECRET: env:DIRECT_LINE_SECRET }
targets:
teams:
type: rest
url: "{{vars.dl}}/conversations/{{session.id}}/activities"
headers: { Authorization: "Bearer {{session.token}}" }
session:
id: "user-{{uuid}}"
open:
url: "{{vars.dl}}/conversations"
headers: { Authorization: "Bearer {{secrets.DIRECT_LINE_SECRET}}" }
body: { user: { id: "{{session}}", name: Marta } }
capture: { id: $.conversationId, token: $.token }
request:
body: { type: message, from: { id: "{{session}}", name: Marta }, text: "{{message}}", locale: en-US }
response:
text: "$.activities[?(@.type == 'message' && @.from.id != '{{session}}')].text"
done: "$.activities[?(@.type == 'endOfConversation')].type"
poll:
url: "{{vars.dl}}/conversations/{{session.id}}/activities?watermark={{cursor}}"
headers: { Authorization: "Bearer {{session.token}}" }
cursor: $.watermark
interval: 500ms
idle: 2s
tests:
- name: book a meeting room
target: teams
persona: { name: Marta, style: "busy, types short messages" }
scenario: { goal: "Book a room for 4 people tomorrow at 10", max_turns: 6 }
assert:
- judge: "The agent confirms a room, a date and a time, or explains why it cannot"
- json_path: { path: "$.activities[*].attachments[?(@.contentType == 'application/vnd.microsoft.card.adaptive')]" }
on_turn: last
- not_contains: "Sorry, something went wrong"
- name: hands off when asked for a person
target: teams
persona: { name: Ken, style: "frustrated" }
scenario: { goal: "Insist on talking to a human about a billing error", max_turns: 4 }
assert:
- handoff: { within_turns: 3 }
The handoff comes from the target’s handoff signal (a json_path over the activities, such as
an event activity named handoff.initiate), declared on the target as for any other type.
A Telegram bot, with tamesu as the Bot API
Telegram’s Bot API only lets a program act as the bot, so tamesu plays the platform instead: it
sends the bot an Update on its webhook and serves the Bot API the bot answers through. Point the
bot at the listener (every Bot API library takes a base URL: TELEGRAM_API_URL, apiURL,
base_url), run the bot, and each test is one chat. Requests to other methods (sendChatAction,
getMe) are acknowledged with reply and ignored; sendMessage carries the turn. Telegram ids
are integers, so the session id is a {{faker.number}} and the body sends it through the
number filter; $.chat_id routes the replies when tests run in parallel.
suite: telegram-bot
tags: [telegram]
vars: { webhook: "${BOT_WEBHOOK:-http://localhost:3000/telegram/webhook}" }
targets:
telegram:
type: rest
url: "{{vars.webhook}}"
session: { id: "{{faker.number}}" }
request:
body:
update_id: 1
message:
message_id: 1
date: 1700000000
chat: { id: "{{session|number}}", type: private, first_name: Marta }
from: { id: "{{session|number}}", is_bot: false, first_name: Marta, language_code: en }
text: "{{message}}"
response:
text: $.text
listen:
addr: 127.0.0.1:8089 # the bot's Bot API base URL is http://127.0.0.1:8089
path: "POST /bot*/sendMessage"
session: $.chat_id
reply: { ok: true, result: { message_id: 1, date: 1700000000, chat: { id: "{{session|number}}", type: private }, text: "" } }
timeout: 20s
idle: 1s
tests:
- name: start and ask for help
target: telegram
persona: { name: Marta, style: "new to the bot" }
scenario:
goal: "Find out what the bot can do, then ask for the opening hours"
max_turns: 4
script:
- say: "/start"
assert:
- judge: "The first reply explains what the bot does"
- contains: "hours"
- json_path: { path: $.reply_markup } # the bot offered a keyboard
on_turn: 1
The bot never touches Telegram during the run, so this suite runs in CI with no token and no phone number. What it does not cover is Telegram itself: delivery, rate limits and how the keyboard looks on a phone.
A WhatsApp bot, with tamesu as the Cloud API
The WhatsApp Cloud API has no consumer side either, so the shape is the same as Telegram’s:
tamesu posts the webhook payload Meta would send for an incoming message, and serves the Graph API
the bot replies through. Set the bot’s Graph API base URL (https://graph.facebook.com in most
SDKs) to {{listen.url}}. Read receipts and typing indicators go to the same /messages path,
and are ignored because they carry no text; template and interactive messages still land in the
turn’s raw payload for json_path.
suite: whatsapp-bot
tags: [whatsapp]
vars:
webhook: "${BOT_WEBHOOK:-http://localhost:3000/whatsapp/webhook}"
phone_id: "123456789012345"
targets:
whatsapp:
type: rest
url: "{{vars.webhook}}" # disable the webhook signature check in the test environment
session: { id: "34{{faker.number}}" } # the user's WhatsApp number, digits only
request:
body:
object: whatsapp_business_account
entry:
- id: "{{vars.phone_id}}"
changes:
- field: messages
value:
messaging_product: whatsapp
metadata: { display_phone_number: "15550000000", phone_number_id: "{{vars.phone_id}}" }
contacts: [{ profile: { name: Marta }, wa_id: "{{session}}" }]
messages: [{ from: "{{session}}", id: "wamid.{{uuid}}", timestamp: "1700000000", type: text, text: { body: "{{message}}" } }]
response:
text: "$.text.body"
listen:
addr: 127.0.0.1:8090 # the bot's Graph API base URL is http://127.0.0.1:8090
path: "POST /*/messages" # /v21.0/{phone_id}/messages
session: $.to
reply: { messaging_product: whatsapp, contacts: [{ wa_id: "{{session}}" }], messages: [{ id: "wamid.{{uuid}}" }] }
idle: 1s
tests:
- name: return window
target: whatsapp
persona: { name: Marta, style: "polite" }
scenario: { goal: "Ask whether shoes bought 40 days ago can be returned", max_turns: 4 }
assert:
- contains: "30 days"
- judge: "The agent states the return window and does not promise an exception"
- json_path: { path: "$.interactive.action.buttons" } # the answer offered buttons
on_turn: 1
The bots behind both simulators are the real ones, with their real prompts, state and backend, so the assertions, captures, grounding checks and packs of any other suite apply unchanged; what stays outside the test is the platform between the user and the bot.
A Voiceflow agent over its Conversations API
Voiceflow’s REST API runs one turn per request: PUT /v1/stable/conversation/{userID} with the
action, answering the agent’s traces. session.open launches the conversation, method: PUT
sends each text action, the JSONPath over the traces joins the agent’s messages, and the end
trace closes the session. Variables and a state reset are before_each HTTP hooks.
suite: voiceflow-agent
tags: [voiceflow]
vars: { project_id: "${VF_PROJECT_ID:-proj_000}", base: https://realtime-api.voiceflow.com/v1/stable }
secrets: { VF_TOKEN: env:VF_TOKEN }
hooks:
before_each:
- http:
url: "{{vars.base}}/conversation-state/{{session}}/variables?projectID={{vars.project_id}}&environmentAlias=main&version=published"
method: PATCH
headers: { Authorization: "Bearer {{secrets.VF_TOKEN}}" }
body: { variables: { customer_name: Marta } }
targets:
agent:
type: rest
url: "{{vars.base}}/conversation/{{session}}?projectID={{vars.project_id}}&environmentAlias=main"
method: PUT
headers: { Authorization: "Bearer {{secrets.VF_TOKEN}}" }
session:
open: # launch the agent; its opening words are traces too
url: "{{vars.base}}/conversation/{{session}}?projectID={{vars.project_id}}&environmentAlias=main"
method: PUT
headers: { Authorization: "Bearer {{secrets.VF_TOKEN}}" }
body: { action: { type: launch, payload: {} }, version: published }
request:
body: { action: { type: text, payload: "{{message}}" }, version: published }
response:
text: "$.traces[?(@.type=='text' || @.type=='speak')].payload.message"
join: "\n"
done: "$.traces[?(@.type=='end')].type"
tests:
- name: return after 40 days
target: agent
persona: { name: Marta, style: "polite, slightly confused" }
scenario: { goal: "Find out if the shoes bought 40 days ago can still be returned", max_turns: 6 }
assert:
- judge: "The agent states the 30-day window and does not promise an exception"
- json_path: { path: "$.traces[?(@.type=='choice')]" } # the first reply offered buttons
on_turn: 1
version: draft runs what you are editing on a project that was never published. The same
agent through Voiceflow’s live WebSocket runtime, a socket.io server, is the handshake
recipe of the REST target with the
project’s API key as authorization, action.trace events as the text and session.ended as
done. A Voiceflow voice agent hears only through telephony, so it is tested by phone or
through its SIP connection with the SIP target: see Voice agents.