REST target

Two servers exchanging envelopes along looping arrows

The rest target tests an agent that answers one user turn per HTTP request: a chatbot endpoint, a webhook-style bot, an agent framework’s HTTP API. tamesu sends each user turn from a body template and reads the agent’s turn back from the response with JSONPaths, from a plain JSON body, from a stream of server-sent events, or from the messages of a websocket.

targets:
  api:
    type: rest
    url: https://api.example.com/v1/chat
    method: POST                                  # default
    headers: { Authorization: "Bearer {{secrets.API_TOKEN}}" }
    request:                                      # how a user turn is sent
      body: { session_id: "{{session}}", message: "{{message}}" }
    response:                                     # how the agent turn is read
      text: $.reply                               # JSONPath
      tool_calls: $.tool_calls[*]                 # names, or {name, args} objects
      done: $.end_session                         # a truthy value ends the session
    streaming: none                               # none | sse | websocket
    session: { id: "{{uuid}}" }                   # rendered once per run

Over a websocket the session is one connection; each user turn is one message out and the messages back until one marks the turn as done, or until nothing arrives for response.idle:

targets:
  ws:
    type: rest
    url: wss://api.example.com/v1/chat/ws
    streaming: websocket
    headers: { Authorization: "Bearer {{secrets.API_TOKEN}}" }
    request:
      open: { type: start, session_id: "{{session}}" }   # sent once after connecting (optional)
      body: { type: message, session_id: "{{session}}", text: "{{message}}" }
    response:
      text: $.delta                               # applied to every message, deltas concatenated
      tool_calls: $.tool_calls[*]
      turn_done: $.done                           # a truthy value ends the turn
      done: $.end_session                         # a truthy value ends the session (and the turn)
      idle: 2s                                    # without a marker, a turn ends after this silence

Replies that arrive somewhere else

A chat channel’s bot does not answer in the response to the message you post: the reply shows up in the channel’s history a moment later, or the bot calls the platform’s send API. Two keys cover those shapes, and session.open creates the conversation first when the platform needs one.

response.poll polls a second endpoint after each turn’s request until a reply has content, then (with idle) until nothing new arrives. The captures of session.open render as {{session.NAME}}, the captures of the turn’s own request (request.capture) as {{sent.NAME}}, and poll.cursor keeps a watermark between polls as {{cursor}}:

targets:
  teams:                                           # a Bot Framework bot through Direct Line
    type: rest
    url: https://directline.botframework.com/v3/directline/conversations/{{session.id}}/activities
    headers: { Authorization: "Bearer {{session.token}}" }
    session:
      open:                                        # once per session: start the conversation
        url: https://directline.botframework.com/v3/directline/conversations
        headers: { Authorization: "Bearer {{secrets.DIRECT_LINE_SECRET}}" }
        capture: { id: $.conversationId, token: $.token }
    request:
      body: { type: message, from: { id: "{{session}}" }, text: "{{message}}" }
    response:
      text: "$.activities[?(@.from.id != '{{session}}')].text"
      poll:
        url: https://directline.botframework.com/v3/directline/conversations/{{session.id}}/activities?watermark={{cursor}}
        headers: { Authorization: "Bearer {{session.token}}" }
        cursor: $.watermark                        # incremental pages: the replies accumulate
        interval: 500ms
        idle: 2s                                   # the bot may answer in several activities

response.listen serves a local URL and reads the reply from the requests the agent makes to it. Point the bot’s API base URL at {{listen.url}} (every Bot API or Graph API client takes one), send it the platform’s webhook payload as the turn, and take the reply out of its call:

targets:
  telegram:                                        # a Telegram bot, with tamesu as the Bot API
    type: rest
    url: "{{vars.bot_webhook}}"                    # where the bot receives updates
    session: { id: "{{faker.number}}" }           # Telegram ids are integers
    request:
      body:
        update_id: 1
        message: { message_id: 1, chat: { id: "{{session|number}}", type: private }, from: { id: "{{session|number}}", first_name: Marta }, text: "{{message}}" }
    response:
      text: $.text                                 # the text the bot passed to sendMessage
      listen:
        addr: 127.0.0.1:8089                       # the bot's TELEGRAM_API_URL is http://127.0.0.1:8089
        path: "POST /bot*/sendMessage"             # the requests that carry replies; others are acknowledged
        session: $.chat_id                         # routes each reply to its session under --parallel
        reply: { ok: true, result: { message_id: 1, chat: { id: "{{session}}" } } }
        idle: 1s

A socket.io server and a handshake

Many bots and agent runtimes speak socket.io rather than plain websocket messages: an Engine.IO handshake, events named and encoded as 42["event", payload], pings to answer. protocol: socketio makes the websocket mode do that framing: every message is a mapping with event and data in both directions, and request.handshake plays the exchanges a session needs before the first turn, each step sending a message and, with wait, reading until a message satisfies a JSONPath, capturing values from it as {{session.NAME}}:

targets:
  runtime:                                            # a socket.io conversational runtime
    type: rest
    url: https://runtime.example.com/v4/interact/socket   # the socket.io endpoint
    streaming: websocket
    protocol: socketio
    request:
      handshake:
        - send: { event: client.start, data: { userID: "{{session}}", projectID: "{{vars.project_id}}", authorization: "{{secrets.PROJECT_KEY}}" } }
          wait: "$[?(@.event=='client.started')].event"
        - send: { event: session.create, data: {} }
          wait: "$[?(@.event=='session.created')].event"
          capture: { session_key: "$[?(@.event=='session.created')].data.sessionKey" }
        - send: { event: action.send, data: { action: { type: launch } } }
          wait: "$[?(@.event=='action.status' && @.data.status=='completed')].event"
      body: { event: action.send, data: { action: { type: text, payload: "{{message}}" } } }
    response:
      text: "$[?(@.event=='action.trace' && @.data.trace.type=='text')].data.trace.payload.message"
      turn_done: "$[?(@.event=='action.status' && @.data.status=='completed')].event"
      done: "$[?(@.event=='session.ended')].event"

A JSONPath that starts with a filter ($[?(...)]) runs against an object payload wrapped in a one-element list, so the filter selects the message itself and the rest of the path reads from it; on a list payload it runs as is. This applies to response.text, tool_calls, done, turn_done, wait and capture, in every mode. Messages that arrive during the handshake and belong to no step (the agent’s greeting) are read with the first turn.

Both modes record every poll reply or incoming request of the turn in result.json, so json_path sees buttons, cards and attachments, and a request the platform refused (a 4xx or 5xx on the turn’s own request) is the turn as it came, so status fails it cleanly. Recipes has Slack, Teams, Telegram and WhatsApp suites built on these keys.

Keys

KeyMeaning
urlThe endpoint. Templates apply ({{vars.x}}, {{secrets.X}}).
methodHTTP method, default POST.
headersRequest headers. Put credentials here as {{secrets.NAME}}; they are substituted inside the request only.
request.bodyThe body template, any JSON shape. {{message}} is the user turn, {{session}} the session id, {{dtmf}} digits (voice scripts). A value that is exactly "{{session|number}}" (any placeholder, the number filter) is sent as a JSON number, for APIs whose ids are integers. Without it tamesu sends {"message": ..., "session_id": ...}. Over a websocket it is the message sent per turn.
request.openwebsocket only: a message template sent once after connecting, for protocols that need a session setup message.
response.textJSONPath of the agent’s reply. Without it the whole body is the reply. Every match is concatenated, so $.choices[*].text works.
response.tool_callsJSONPath of the tool calls: strings are names; objects take name/tool/function and args/arguments/input (an object or a JSON string).
response.doneJSONPath of the end-of-session flag; a truthy value (true, "done", a non-zero number) ends the conversation.
response.turn_donewebsocket only: JSONPath of the end-of-turn flag, checked on every message; a truthy value completes the turn. A message that ends the session (response.done) arriving within 50 ms of the turn’s end is read with the turn. Without it (and without response.done firing) the turn completes after response.idle without a message.
response.idlewebsocket only: the silence that completes a turn when no message marked it (default 2s). Set it above the agent’s longest pause between chunks.
streamingnone reads one JSON body per turn. sse reads data: events until the stream ends or [DONE]; response.text is applied to every event and the deltas are concatenated. A text/event-stream content type switches to SSE on its own. websocket keeps one connection for the session (ws://, wss://, or http(s):// upgraded) and reads messages per turn as described above; messages that arrive between turns (a greeting, a keepalive) are read with the next turn, and a message that is not JSON is appended to the reply as text.
session.idThe session id template, rendered once when the session starts (default {{uuid}}).
session.openA request made once per run, before the target starts: url, method (default POST), headers, body and capture (JSONPaths by name into its JSON reply, rendered as {{session.NAME}} in every later template). A 4xx or 5xx fails the run. It applies to every target type, not only rest.
protocolsocketio with streaming: websocket: socket.io framing; messages are { event, data } both ways and the server’s pings are answered.
request.handshakewebsocket only: steps played once after connecting (after request.open), each with send (a message template), wait (a JSONPath that must be truthy on a received message), capture (JSONPaths by name into that message, as {{session.NAME}}) and timeout.
response.joinPlain JSON: the separator between several matches of response.text (a newline between the messages of one reply); default none.
request.captureWith response.poll: JSONPaths by name into the reply to the turn’s request, rendered as {{sent.NAME}} in the poll of that turn (the timestamp of the message you posted, to poll after it).
response.pollThe reply is read by polling: url (templates, {{cursor}}), method (default GET), headers, body, interval (default 500ms), timeout (default the target’s 60s), idle (keep polling after the first reply until nothing new for this long) and cursor (a JSONPath into each poll reply whose value is the next {{cursor}}; with it the polls accumulate, without it each poll replaces the last). response.text, tool_calls and done read the poll replies.
response.listenThe reply is read from the agent’s own requests to a URL tamesu serves: addr (default a free port on 127.0.0.1; {{listen.url}} renders it), path (an optional method and a path with * wildcards; a trailing / matches a prefix; default every request), session (a JSONPath into the incoming payload holding the session id, to route under --parallel), reply and status answered to the agent (default {"ok": true}, 200), timeout and idle. JSON, form and multipart bodies and query strings are all read; requests outside path, and requests without content, are acknowledged but do not complete the turn.

A response that is not JSON is taken as the reply text, with its HTTP status recorded, so a 502 upstream down page fails an assertion rather than crashing the run.

Metrics

MetricDefinition
turn_latency_ms (_p50, _p95, _max)the request was sent → the agent turn is complete (the response body was read)
time_to_first_token_msthe request was sent → the first SSE event or websocket message (plain JSON: the end of the body; poll or listen: the first reply with content)
turnsthe number of agent turns
http_requeststhe number of requests of the session (none and sse)
ws_messages_sent, ws_messages_receivedthe messages of the session (websocket)
poll_requeststhe polls of the session (response.poll)
webhooks_receivedthe requests the agent made to the listener that carried a reply (response.listen)

Timestamps are taken around the HTTP call, never from a model.

Assertions that apply

Every assertion of Concepts except the voice ones; status checks every turn’s HTTP status (over a websocket every turn carries the handshake’s 101) and json_path runs against every turn’s response body (every event’s payload for SSE, every message for a websocket):

assert:
  - status: 200
  - json_path: { path: $.session_id }                 # must exist on every turn
  - json_path: { path: $.end_session, equals: true }
    on_turn: last
  - tool_called: { name: lookup_policy, args: { topic: returns } }

Example

examples/rest-agent is a tiny Genkit agent for a shoe store with the suite that tests it, over HTTP (POST /chat) and over a websocket (GET /ws, chunked replies), in two project configurations: real models (tamesu.yaml) and the fake provider (tamesu.llmfake.yaml, no keys needed).