Skip to content

Headless Agents (No UI Required)

An agent in Wetel is not tied to any particular frontend. The workflow engine, the LLM orchestration, and the session lifecycle all live behind the GraphQL API — there is no requirement to render any Wetel-provided UI component to run one.

This page doesn’t re-document the SDK mutations themselves (see Core API Flow and the Sessions API reference for sdkStart, sdkSendMessage, and sdkEndSession). It’s here to make the concept explicit, because it’s easy to assume — wrongly — that a Wetel agent needs a Wetel-hosted widget or embed to function.

The primitive: a session is just three GraphQL calls

Section titled “The primitive: a session is just three GraphQL calls”

Any agent, driven by any workflow, is reachable from anywhere that can make an authenticated HTTP request:

  1. sdkStart — opens a session against an agent, returns a sessionId.
  2. sdkSendMessage — sends one turn of input, returns the agent’s reply.
  3. sdkEndSession — closes the session.

That’s the entire contract. A script, a scheduled job, a backend microservice, or a chatbot adapter can drive this loop with no browser, no rendering layer, and no Wetel SDK/widget of any kind involved. See API Reference Overview for authentication and general request shape, and Authentication for header requirements.

When there is no conversation at all: runWorkflowTask

Section titled “When there is no conversation at all: runWorkflowTask”

The three-call loop above is the right shape when something is genuinely talking to the agent — there is a message to send and a reply to relay back out. Some headless work isn’t like that: an orchestrator already knows what it wants done and has the facts to do it with, and nobody is waiting on a reply.

runWorkflowTask is the entry point for that case. One API-key-authenticated mutation runs an agent’s published workflow with a structured context — a flat map of variables plus an optional attachment — with no message text, no avatarToken to carry, and no subscription to collect from. Structured facts arrive as real context keys your nodes can gate on, instead of being packed into message text for an LLM node to pull back out.

It is asynchronous: a successful return means the run was accepted and enqueued, never that it succeeded. Read the outcome from the run itself using the sessionId it returns.

Because the session lifecycle is just GraphQL calls, you can put anything in front of it that can translate an external event into a sdkSendMessage call and relay the reply back out. In practice, this is exactly what’s needed to connect an agent to a messaging channel — a Telegram bot, a WhatsApp Business webhook, a Slack app, an SMS gateway, an internal support tool — or to embed an agent inside an existing backend service that has nothing to do with chat at all (a CLI tool, a batch pipeline that asks an agent to summarize something, a voice IVR bridge).

The agent and workflow definitions themselves don’t need to know or care what’s driving them. The same published workflow can be exercised from the Wetel-hosted web widget, a custom app, and a headless script simultaneously.

Telegram is now a packaged, zero-code connector

Section titled “Telegram is now a packaged, zero-code connector”

Wetel provides a pre-built, zero-code way to connect an agent directly to Telegram — see Channels for the full reference. Four GraphQL mutations (createChannelConnectorupdateChannelConnectorCredentialsactivateChannelConnector, and deactivateChannelConnector to pause) take a Telegram bot token and produce a live, webhook-driven bridge with no bridge process for you to run or host — Wetel receives the inbound message, drives the session, and sends the reply back out.

WhatsApp, Slack, and Lark are not yet implemented — the ChannelType enum has entries for them, but every operation against one of those types currently returns a clear error, not a silent no-op. Build your own bridge for those platforms today using the pattern below (the same one Wetel’s own Telegram connector is built on internally), and check Channels: What’s live today before assuming a new one has shipped.

Beyond Telegram, packaged connectors for more platforms are on the roadmap, roughly in the order we’re tackling them: WhatsApp, Slack, Lark (Feishu), Discord, Signal, iMessage, Google Chat, Microsoft Teams, WeChat, WeCom, QQ, LINE, Zalo, and Nostr.

This list will grow or reorder based on demand — it’s a direction, not a committed schedule. Build the adapter yourself for any of these in the meantime using the pattern below.

Worked example: building your own bridge (for a platform without a packaged connector)

Section titled “Worked example: building your own bridge (for a platform without a packaged connector)”

This is a real, working shape for the “connect an agent to a messaging channel” pattern for any platform Wetel doesn’t yet have a packaged Channel Connector for — using Telegram as the concrete example here only because it’s the most familiar API to demonstrate against; for Telegram itself, use the packaged connector above instead of building this yourself. The same approach adapts to any channel with a webhook or polling-style API (WhatsApp Business, Slack, SMS gateways).

Long-polling instead of a webhook. Telegram (like most chat platforms) supports both a webhook push model and a getUpdates long-poll model. A webhook needs a public HTTPS URL reachable from the provider’s servers — real infrastructure to stand up. Long-polling needs none of that: the bridge process makes outbound-only HTTP calls, so it can run anywhere, including a laptop behind NAT, with zero exposed ports. Trade-off: one process per bridge instance (no horizontal scaling without moving to a webhook), fine for low-to-moderate volume.

Session mapping: one Wetel session per external chat, held in memory (or Redis for a multi-process deployment):

// chatId -> { sessionId, avatarToken }
const sessions = new Map();
async function handleMessage(chatId, text) {
let session = sessions.get(chatId);
if (!session) {
session = await sdkStart(); // opens once per NEW external conversation
sessions.set(chatId, session);
}
const replyText = await turn(session.avatarToken, session.sessionId, text);
return replyText;
}

sdkSendMessage only returns an ack — the actual reply arrives over the sessionEvents subscription (see Events & Subscriptions). A bridge with no long-lived UI to stream into still needs to subscribe, send, and collect the reply before it can hand anything back to the channel:

async function turn(avatarToken, sessionId, text) {
const client = createClient({
url: WETEL_WS_ENDPOINT,
webSocketImpl: WebSocket, // Node has no native WebSocket in some LTS versions — use the `ws` package
connectionParams: {
Authorization: `Bearer ${avatarToken}`,
"x-huat-platform": "customer",
},
});
await new Promise((resolve, reject) => {
client.on("connected", resolve);
client.on("error", reject);
});
const iterator = client.iterate({
// sessionId is ID! here, not Int! — see the caution box in
// Events & Subscriptions. Easy to get backwards since every other
// session-related field in the API is Int!.
query: `subscription($sessionId: ID!) {
sessionEvents(sessionId: $sessionId) {
__typename
... on AiResponseEvent { text isFinal }
... on SessionEndedEvent { durationSeconds }
}
}`,
variables: { sessionId: String(sessionId) },
});
await sendMessage(avatarToken, sessionId, text); // sdkSendMessage
// Collect reply text until a quiet period (no new events) — Wetel's event
// stream has no explicit "end of turn" marker, so a bounded quiet-window
// collection loop (rather than waiting for a single event) is the
// correct pattern for a synchronous-looking headless integration.
const parts = [];
for await (const { data } of iterateWithQuietTimeout(iterator, 2000)) {
const event = data?.sessionEvents;
if (event?.__typename === "AiResponseEvent" && event.text?.trim()) {
parts.push(event.text.trim());
}
}
client.dispose();
return parts.join("\n\n");
}

Forward the collected reply back through the channel’s own send API — for Telegram, POST https://api.telegram.org/bot<token>/sendMessage. That’s the whole loop: receive → sdkStart (once per chat) → subscribe → sdkSendMessage → collect → send back.

This same shape — long-poll or webhook in, session-per-conversation mapping, subscribe before send, forward the collected reply out — is the correct template for any headless channel integration, not just Telegram.