Skip to content

Changelog

Notable API-facing changes, newest first. This tracks what integrators need to know — not every internal change, just anything that adds a capability, fixes a real bug you might have hit, or changes behavior you may be relying on.

  • Added (on staging; code ships with the next production release; pilot, off by default): two owner-only reads for the outbound self-test. outboundAvailability says whether a “Call my phone” self-test could start now (available plus a plain reason, masked phone, allowance) and outboundCall(id) returns the state of one call (status, duration, end reason, masked last four digits and country, never the number). Workspace owner only; both are reads and never place a call. Not in the production schema yet, and outbound calling stays off. See Reading a call.

  • Added (in production since 2026-11-05): the knowledge_search workflow node looks up passages of one knowledge base and writes them to a variable as [{ chunkId, documentId, score, text }], with no model call. A lookup error fails the node and can be routed on a failure edge; no match is an empty list. NodeType gains KNOWLEDGE_SEARCH, so a client that lists node types must accept it. See Knowledge Search Node.

  • Added (in production since 2026-11-05): request changes on approvals. decideApproval accepts decision: REQUEST_CHANGES (workspace owner only, comment required: it is the feedback). The request ends as the new status CHANGES_REQUESTED and the run resumes on an optional changes_requested edge of the approval node, normally back to the node that drafted the content, with the feedback as {{approval.comment}}. The revised draft opens a new request. ApprovalDto gains revision: Int! (how many times it was sent back, 0 for a first draft); at most 3 revisions, after which only APPROVE or REJECT is accepted. REQUEST_CHANGES is refused (BAD_REQUEST) without a comment, on an approval step with no changes_requested edge, and after 3 revisions. Existing workflows are unchanged: the edge is optional. A query that selects revision, or a filter that sends CHANGES_REQUESTED, fails on an API that has not released this yet. See Approval Node and Human approval: a walkthrough.

  • Added (in production since 2026-11-05): a sub_agent target whose workflow pauses at an approval node is no longer reported as a failure. Its entry in resultVar has the new status: "awaiting_approval" plus approvalId (the open request in the owner’s inbox) and, when the target had already spoken, text. The change is additive: a graph that checks status == "ok" is unaffected, and a router or switch can add an awaiting_approval branch. The owner’s decision resumes the target’s own run, not the master’s. Publishing such a workflow through publishWorkflow is no longer refused (see the next entry). A client that models the status as a closed ok | timeout | error set must accept the new value. See A target that waits for an owner approval.

  • Changed (in production since 2026-11-05): publishWorkflow no longer refuses a workflow that contains an approval node when another workflow’s sub_agent node targets the agent that runs it. The refusal text (“run as a sub-agent by workflow N … an approval cannot run inside a sub-agent dispatch”) is gone; a graph that publishes now needs no change on your side. The workspace app screens for the delegating pattern are still on the staging-backed app. See A target that waits for an owner approval.

  • Site: a new product tour walks through a Sales-first AI Office in a demo workspace, with a status on every step. No API change.

  • Added (in production since 2026-11-05): text helpers in condition, switch and set_variable expressions, so a graph can check text without an llm node. Existing expressions are unchanged. See Condition Node.

  • Added (code in production since 2026-11-05, switched off until enabled for a workspace; pilot): startOutboundCall(input: StartOutboundCallInput!): StartOutboundCallResult! makes an AI employee phone the workspace owner’s own number. Workspace owner only, self-test only (selfTest: true; the destination must be the owner’s profile phone, prefixes +1 and +60 by default), off unless the platform and the workspace switches are on (FORBIDDEN otherwise), hard maximum length 15 to 300 seconds (default 120). It returns queued at once; the destination number is stored only as a keyed hash plus its last four digits and country. Call states are dialing, ringing, answered, voicemail, no_answer, busy, declined, failed and completed; completion with duration and end reason is recorded when an answered call ends, and voicemail is detected and hung up. There is no public query for a call’s later states yet. See Telephony API reference.

  • Added (code in production since 2026-11-05, switched off until enabled for a workspace): sdkStartVoiceCall takes an optional engine: WORKFLOW (default) or LIVE, a speech-to-speech model that answers from the agent’s instructions only (no workflow, no tools). LIVE is refused unless the deployment has it enabled. The voice worker stores a live call’s transcript with the new sdkRecordVoiceTranscript (called with the call’s own token, once per call, not from a browser). See Telephony API reference.

  • Added (code in production since 2026-11-05, switched off until enabled for a workspace): sendOwnerDigestNow (workspace owner only) builds the weekly ops digest now (conversations, messages, workflow runs by outcome, approvals, files created, AI cost and the most active AI employees over seven days; counts and AI employee names only) and sends it as an in-app notice to the workspace owners. Limited to 3 calls an hour; FORBIDDEN while the feature is switched off on the server.

  • Now on in production: the generate_artifact node can produce documents and pages, spoken voice-over files (audio_speech), jingles (audio_music) and narrated video scripts (video_script with narrate: true) on the production API. Each workspace has daily limits for speech and music files (the platform defaults apply). Image generation (image) stays off in production for now. See Generate files, voice and video.
  • Now on in production: workspace Office presence and room chat, and per-run cost attribution (internal usage records). These affect the workspace app, not the integration API.
  • Added (on staging; switched off in production): generate_artifact accepts narrate: true for artifactType: "video_script": one spoken voice file per scene, saved with the video, counted against the workspace’s daily speech limit; off unless the platform’s video narration switch is on. A video script’s default length is now about 45 seconds unless the brief names one. The workspace app can play a video script, and “Download video” records an MP4 or WebM in the browser. See Generate Artifact Node.
  • Added (on staging once released; switched off in production): generate_artifact accepts artifactType: "video_script": the model writes a short narrated explainer as 1 to 8 scenes, the platform validates it (length limits, no URLs or markup, last scene a call to action) and stores only a valid manifest as an artifact of kind video_script; an invalid answer fails the node with BLOCKED. The workspace app plays it in the Documents window; it is not an MP4. See Generate Artifact Node.
  • Added (on staging; in the production schema, switched off): the generate_artifact node can now produce audio. artifactType accepts audio_speech (the prompt is read aloud and stored as a WAV file; optional voice: Kore, Charon, Fenrir, Aoede or Puck) and audio_music (a short piece composed from the prompt, stored as an MP3 file). Audio has its own platform switch, off by default, a per-workspace daily limit (10 speech and 5 music files by default) and a 20 MB cap per file. The artifact kinds are audio_speech and audio_music. See Generate Artifact Node. Do not build a customer-facing flow on it until Wetel has told you it is on for your environment.
  • Added (in production since 2026-10-30): updateKnowledgeBase(input: { id, name, description }): rename a knowledge base or change its description without touching its documents. See Knowledge and embeddings.
  • Added (in production since 2026-10-30): workspaceModels and ownerChannelConnectors (workspace owner only, read-only): the models the workspace may use and its default, and the workspace’s channel connectors with their linked AI employee, status and last activity (never credentials or message text). See Tenant and Channels.
  • Added (in production since 2026-10-30; attribution off by default): tenantCostByAgent (workspace owner only): what each AI employee cost in a window. Model calls are now recorded with the AI employee, session and run they belong to, but attribution is a platform setting that is off until Wetel switches it on: while it is off agents is empty and attributionEnabled is false, and calls with no agent are reported as unattributed (isFloor). Credits are approximate. See Tenant.
  • Added (in production since 2026-10-30): test chats. startSession takes isTest, and SessionDto and SessionActivitySummaryDto return isTest. A test chat is hidden from sessions, sessionActivitySummaries, sessionCount and activity counts and is never evaluated; a workspace owner can list them with includeTests: true. Their cost still counts. See Sessions.
  • Added (in production since 2026-10-30): withdrawRelease(releaseId): the author (or the owner) takes back a PENDING release so the draft can be edited and submitted again. See Tenant.
  • Added (in production since 2026-10-29): release audiences. setReleaseAudience (owner only) limits an approved release to SELECTED colleagues or a private TEST_GROUP; releaseVisibility says who may use released resources; ResourceReleaseDto gains audienceEnforced and audienceUserIds. Audiences are enforced only while a workspace has member-built drafts on (the default is off). While it is on, only the owner can create an API key. Turning member-built drafts on now releases to everyone every existing resource that has no release and was not made by a member. This replaces the earlier note that releasing did not change who can use a resource. See Release audience.
  • Added (in schema; off by default, do not rely on it): the generate_artifact workflow node, which asks a model to write a markdown document or an HTML page, or to draw an image, and stores it as an artifact. It is behind platform switches that are off, so it fails with DISABLED until Wetel enables them. ArtifactDto gains agentId, generator, hasThumbnail, isTest and visibility (reserved, not enforced); artifacts takes includeTests (owner only); artifactDownloadUrl takes thumbnail; the artifact errorCode can now be BLOCKED. See Generate Artifact Node and Artifacts.
  • Changed (in production since 2026-10-31): a typed dashboard chat or a test chat with an agent that has no workflow now gets the agent’s persona without the spoken-style voice rules. Sessions from startAvatarDemoSession, sdkStart, channels and voice calls are unchanged. See Sessions.
  • Reminder: a request body sent with a Content-Encoding other than identity is answered with HTTP 415 (UNSUPPORTED_CONTENT_ENCODING) since 2026-10-23. Send it uncompressed; see Troubleshooting.
  • Added (available on staging; production rollout pending): email verification. requestEmailVerification (signed in; at most 3 calls per 15 minutes per user) emails a 6-digit code to your own address and always answers true; confirmEmailVerification(code: String!) (at most 5 calls per 15 minutes per user) marks the address verified. User.emailVerified (Boolean) is visible on the current user. Enforcement is a platform setting that is off by default: when on, an account whose email is not verified is refused with FORBIDDEN (HTTP 403) and extensions.code EMAIL_NOT_VERIFIED (“Verify your email address first”) on publishWorkflow, rollbackWorkflow, createNamedApiKey, generateApiKey, inviteTenantUser, resendTenantInvite, hireAgentTemplate, bindAgentTemplateSlot and provideAgentTemplateSlotCredential. Accounts created or linked through WebbyX One sign-in and accounts created by accepting a workspace invite count as verified. See Email verification.
  • Changed (available on staging; production rollout pending): a throttled GraphQL call now returns extensions.code TOO_MANY_REQUESTS (it was INTERNAL_SERVER_ERROR with the message ThrottlerException), a message like “Too many requests. Try again in about 2 minutes.” and extensions.retryAfterSeconds. extensions.status stays 429 and the HTTP status of the response is unchanged. See Error handling and Rate limiting.
  • Now in production: since 2026-10-29 (backend release of that night): the owner createTextArtifact, the member-built drafts and releases for knowledge bases, custom actions, agents and workflows (off per workspace until an owner enables member-built drafts), the per-workspace cap on headless tasks in flight (TENANT_BUSY), the owner email on a workflow failure and the PLAINTEXT_SECRET_IN_CONFIG diagnostic. The owner audit view (tenantAuditLogs, requestAuditLogExport, auditLogExport) is not in production yet.
  • Docs: new Channel capabilities page: what each channel supports today (text, groups, voice, files, approvals, hand-off, limits), checked against the code. The Channels page now lists the API channel type, says that only PDF and image files are accepted from Telegram, and shows the current attachment URL form (a signed gateway link; the Telegram bot token never appears in it).
  • Added (in production since 2026-10-29): member-built drafts and owner releases for knowledge bases and custom actions: setMemberBuilderForTenant, Tenant.memberBuilder, submitRelease, myReleases, releasesByState, decideRelease, revokeRelease, and createdByMe / releaseState on knowledgeBases and customActions. Off by default. A member’s draft custom action carries no credentials; publishing a workflow is refused while it uses an unreleased knowledge base or custom action. See Tenant. Off by default per workspace: nothing changes until an owner turns on member-built drafts (Tenant.memberBuilder, false until then).
  • Added (in production since 2026-10-29): releases now also cover agents and workflows: submitRelease accepts resourceType AGENT and WORKFLOW, and agents and workflows return createdByMe and releaseState. While member-built drafts are on, an agent a member creates starts INACTIVE and a member cannot set its workflowId or external sync fields (BAD_REQUEST); a member can publish a workflow only once it is released, and publishing is also refused while a sub-agent node points at an unreleased agent. A resource a member may not see answers NOT_FOUND (an agent or workflow reads as null). See Tenant. The release primitive stays off for a workspace until its owner enables member-built drafts.
  • Added (available on staging; production rollout pending): tenantAuditLogs, requestAuditLogExport and auditLogExport (workspace owner only): the audit trail of the whole workspace and a CSV export (at most 100,000 rows, download link valid for 15 minutes). Field names only, never values; reads, chat sessions and messages and workflow runs are not audited. See Tenant.
  • Added (in production since 2026-10-29): createTextArtifact (workspace owner only): store an HTML page or a Markdown document as an artifact (kind html_page or markdown, at most 262144 bytes). The file is stored as plain text and must be shown through a sanitised, sandboxed preview. There is no delete mutation yet. See Artifacts.
  • Added (in production since 2026-10-29): a per-workspace cap on headless tasks in flight (default 10). runWorkflowTask and scheduled fires over the cap are refused with HTTP 429 and recorded as TENANT_BUSY. See Rate limiting and runWorkflowTask.
  • Added (in production since 2026-10-29): with settings.notifyOnFailure set to OWNERS, each active owner with an address also gets an email, not only the in-app notice. It holds the workflow name, run id and time, never error text. See Workflows.
  • Added (in production since 2026-10-29): workflow diagnostic PLAINTEXT_SECRET_IN_CONFIG (warning, never blocks save or publish) for a likely literal credential in a webhook, tool or action node’s configuration; the message never repeats the value. See Workflow diagnostics.
  • Added (in production since 2026-10-27): exportGuestSubject and eraseGuestSubject (workspace owner only) for data-subject requests about one guest: export their data as JSON, or erase it with a required dryRun flag. See Tenant.

  • Changed (in production since 2026-10-27): in the Group Brain API, a follow-up from the person the agent just answered is no longer answered by rule: it goes to the judge as a follow-up candidate, so off-topic messages get SILENT. In mention_only mode the follow-up window no longer applies. The follow_up reason code remains. If your client depends on the agent answering every follow-up, tell us.

  • Added (in production since 2026-10-27): publishSentences on the llm node: send each finished sentence to the client while the model is still writing (partial AI_RESPONSE events with isFinal: false, then an empty final event). Off by default and per node; refused with outputSchema and with streamOutput: false.

  • Docs: the Group Brain API now explains the follow_up rule and the continuityMs window, and the mention-only recipe has a section on running more than one agent in the same group. No API change.

  • Docs: new Telephony primer: the phone network, SIP and RTP, why phone audio is 8 kHz, the listen-think-speak loop and a glossary, with how it maps to Wetel Telephony today. No API change.

  • Docs: new telephony page How a call flows: sequence and component diagrams for a call to a demo phone number and for a Wetel-run outbound test, what each part does, what you can try today and what is not available. No API change; telephony is still a test-environment capability on request.

  • Added: signIn mutation — authenticates with email/phone + password and supports two-factor authentication in a single flow. Returns a SignInResult with status SIGNED_IN, TWO_FACTOR_REQUIRED, or TWO_FACTOR_ENROLMENT_REQUIRED. Two-factor is completed with completeSignInWithTwoFactor (submit authenticator code) and, for first-time set-up, generateTwoFactorForSignIn (get the QR code). See Signing in with two-factor support.

  • Changed: login now refuses accounts that have two-factor authentication (or whose workspace requires it) with FORBIDDEN and code TWO_FACTOR_REQUIRED or TWO_FACTOR_ENROLMENT_REQUIRED. Integrations with such accounts must migrate to signIn + completeSignInWithTwoFactor.

  • Added: signOutEverywhere — signs the current user out of every session on every device; all existing tokens are refused from the next request. revokeUserSessions (owner only) — does the same for a specific user of your workspace. See Session revocation.

  • Added: Workspace invites — inviteTenantUser, acceptTenantInvite, resendTenantInvite, revokeTenantInvite, tenantInvites. Owners invite by email; the invitee receives a one-time code (never returned by the API) valid for 7 days. See Workspace invites.

  • Added: setRequireTwoFactorForTenant (owner only) — enforce two-factor authentication for every password sign-in to the workspace. resetTenantUserTwoFactor (owner only) — turn off two-factor for a user who lost their authenticator (also revokes their sessions). See Workspace security.

  • Added: workspaceSecurityEvents (owner only) — paginated security event log for your workspace (sign-ins, failures, locks, password events, owner actions, invite events, two-factor policy changes). mySignInHistory — the caller’s own account security history. See Workspace security.

  • In production since 2026-10-27: structured output (outputSchema) and per-node timeoutMs on I/O nodes. See Structured output and Workflows API reference.

  • Now in production: the 2026-10-22 and 2026-10-23 changes below that were marked “available on staging” are live in production since 2026-10-24: the per-node retry policy (with attempts/retryOutcome on the trace and the RETRY_NOT_IDEMPOTENT diagnostic), mock mode for draft tests and regression replays (draftSideEffects, draftMocks, draftMockLlm, mockResponse, runRegressionScenario’s sideEffects and its mock-mode default, WorkflowRunDto.mocked), the empty end-of-turn event (turnComplete: true with empty text) and the Group Brain API, which stays shadow-only: it needs a named API key with the groups:decide scope, and every decision is answered SILENT until Wetel staff switch the profile live. A production draft test now runs in mock mode by default; pass draftSideEffects: LIVE (workspace owner only) to run every node for real. Telephony (sdkStartVoiceCall) is unchanged: staging only, on request, not in production.
  • Added (available on staging; in production since 2026-10-27): structured output for the llm node. outputSchema (a JSON Schema subset: root object, at most 3 levels of objects, at most 20 properties per object, at most 4 KB, additionalProperties: false, every property in required, optional values as nullable [type, "null"], enum/pattern/length and range limits; format, anyOf, $ref, nullable enums and backtracking-prone patterns are refused with BAD_USER_INPUT) makes the node store the parsed, validated object in outputVar; flatten also writes each top-level property to its own variable; structuredMode (auto or prompt) chooses how the model is asked. An invalid reply gets one repair call, billed as its own model call (none after a reply cut off at the token limit); a reply still invalid fails the node, which follows its failure edge with _lastNodeErrorCode INVALID_DATA. Cannot be combined with streamOutput: true, extractFirstInteger, matchAnyOf or extractAlphanumericId. Trace rows gain structuredMode, repaired and structuredRaw; new info diagnostic LLM_STRUCTURED_WITHOUT_FAILURE_EDGE, and ROUTER_DOTTED_VARIABLE_OF_TEXT no longer fires for a structured node. A valid reply has the right shape, not necessarily the right values: check them in a later node or an approval step. Nodes without outputSchema are unchanged.
  • Docs (correction): the telephony overview now has one “what works today, what does not, what is next” table, and the telephony roadmap, controls and compliance and roadmap pages match it. No API change. Corrections: asking for a person gets a spoken callback offer, not a live transfer; live calls are tested in English only; ordinary phone calls (including Malaysian +60 numbers, inbound or outbound) and joining Meet, Zoom or Teams meetings are not available; the overview’s “no outbound sales calls, and it will not” answer now states the actual policy: no cold outbound sales calls, while callbacks the person asked for and follow-ups with people who agreed to be called are planned at specification stage, with no date. New FAQ answers cover +60 numbers, meetings and languages.
  • Docs: new cookbook recipe Build a reliable workflow, no API change: a lookup that retries with an idempotent policy, reads its result with set_variable and branches with a switch; a create call whose retry policy leaves out TIMEOUT (and the RETRY_NOT_IDEMPOTENT warning if you add it); failureResponse and notifyOnFailure as the safety net; one mock-mode test turn per branch with draftMocks; and publish with a note, then rollbackWorkflow. The retry and mock-mode parts are available on production from 2026-10-24.
  • Added (available on staging; in production since 2026-10-24): a per-node retry policy on tool, action and webhook nodes: maxAttempts (1 to 4, required), backoff, delayMs, maxDelayMs, jitter, on (any of TIMEOUT, UNREACHABLE, RATE_LIMITED, UPSTREAM_ERROR; default UNREACHABLE and RATE_LIMITED), idempotent and deadlineMs. A request that may already have reached the service is resent only when it is idempotent. Without retry, a node keeps its current behaviour. The trace row of a node that retried shows attempts and retryOutcome, and a new WARNING diagnostic, RETRY_NOT_IDEMPOTENT, flags a policy whose TIMEOUT/UPSTREAM_ERROR classes can never be retried.
  • Added (available on staging; in production since 2026-10-24; shadow-only): the Group Brain API for platforms that run their own group chats: groupDecide (should the assistant speak, stay silent or wait for this batch of messages), groupReportOutcome, groupSetState and groupDecisions. It needs a named API key with the groups:decide scope and a channel connector of type API. Every decision is recorded and answered SILENT until Wetel staff switch the profile live.
  • Changed (live, breaking for a few clients): security hardening, in production since 2026-10-23. Compressed request bodies are refused: a request to the Wetel APIs (GraphQL, the REST endpoints, the speech and avatar service, and the endpoints that receive webhooks) that carries a Content-Encoding header other than identity is answered with HTTP 415; the application’s own refusal body is {"statusCode":415,"code":"UNSUPPORTED_CONTENT_ENCODING",...}, and the public edge may answer a plain 415 first. Send the body uncompressed (no Content-Encoding). Almost every client already does: browsers, fetch, axios and the usual GraphQL clients do not compress a request body unless you ask them to. Response compression is not affected; Accept-Encoding still works as before. See Send plain request bodies.
  • Changed (live): outbound responses must not be Brotli-compressed. When Wetel calls a URL you configured, it now declines Brotli and refuses a response that arrives Brotli-encoded: webhook and action node calls and file fetches ask for Accept-Encoding: identity and refuse any response that arrives with a Content-Encoding; MCP connector calls, externalSyncUrl deliveries and URL ingestion ask for gzip, deflate and refuse a Brotli response. A target that compresses regardless fails that call with a short error naming the encoding. Turn off response compression, or at least Brotli, for the endpoints Wetel calls. See Responses Wetel cannot read.
  • Docs: three new cookbook recipes for features that are live in production, with no API change: Answer when mentioned (an agent in your platform’s group chats that answers only when mentioned or replied to, built on sdkStart/sdkSendMessage with one session per group), Fail gracefully (failure edges, a switch on _lastNodeErrorCode, and _lastNodeErrorPublic as the only error text users see) and Delegate to a specialist (sub_agent with inputs and outputs). The partner template page is now Provision agents for your customers with a template hire, same URL and anchors, with a one-agent-per-customer walkthrough, a test checklist and its limits.
  • Docs (correction): the telephony overview, controls and compliance and roadmap pages listed per-key daily voice call caps as planned or staging only. The cap is in the production code as well (1,000 call requests per API key per UTC day by default, set per environment; past it sdkStartVoiceCall answers INTERNAL_SERVER_ERROR with extensions.status 429), so the “still staging only” note in the entry below was wrong about the cap. Voice calls themselves remain staging only, on request, so in practice the cap applies only there.
  • Added (available on staging; in production since 2026-10-24): mock mode for draft tests and regression replays. A draft test (sendMessage with testDraft: true) now runs with draftSideEffects: MOCK by default: tool, action, webhook and pdf_export nodes build their request exactly as live and then return a mock result instead of calling out (no outbound call, no credentials, no file written, no metering), a sub_agent node’s children run in mock mode too, and LLM nodes still call the model unless the test passes draftMockLlm: true or mocks them. draftMocks scripts a result ({ "output": ... }) or a failure ({ "fail": "..." }, which follows the node’s failure edge) per node, and the new node config field mockResponse (up to 16 KB, mockable node types only) sets a node’s default mock. At most 20 real model calls per test turn. draftSideEffects: LIVE runs every node for real and is limited to the workspace owner. runRegressionScenario also defaults to mock mode, with mocks taken from the scenario’s recorded run, and takes sideEffects: LIVE (owner only); each result records sideEffectMode. A mocked run has WorkflowRunDto.mocked: true and each mocked trace row "mocked": true. New info diagnostic: MOCK_RESPONSE_SET. Until 2026-10-24, in production a draft test and a replay still ran every node for real. See Testing a Workflow Safely.
  • Changed (available on staging; in production since 2026-10-24): a workflow run that completes without a reply marked turnComplete: true now ends the turn with one empty AiResponseEvent (text empty, turnComplete: true), the same event an approval pause sends. Skip events with empty text when rendering; nothing is stored. See A turn can end with an empty event.
  • Now in production: the 2026-10-21 changes below that were marked “available on staging” (expressions that cannot assign, _lastNodeErrorCode/_lastNodeErrorPublic, OUTBOUND_URL_REFUSED and BAD_GATEWAY, normalize: "numbers" and the speech-to-text billing change) are live in production since 2026-10-21. The voice call daily cap is still staging only.
  • Changed (live, breaking): changePassword requires currentPassword in PasswordInput (since 2026-10-21), so a session token alone can no longer change a password, and it returns true on success (it returned false before). A wrong current password or an unchanged one is BAD_REQUEST; at most 5 calls per 15 minutes per user. Send currentPassword from your change-password form; accounts created through single sign-on set a password with generateOTP and forgotPasswordWithOTP instead.
  • Changed (available on staging; in production since 2026-10-21): workflow expressions can no longer assign. The = operator (x = 5) and function definitions (f(a) = a + 1) are refused in condition, switch and set_variable expressions, and a condition is evaluated on a copy of the run’s variables, so an expression can never change them. ==, !=, <=, >= and built-in functions are unaffected (map(sqrt, items) still works; an inline callback such as map(f(x) = x * 2, items) does not). A switch or set_variable expression with = is refused at save with BAD_USER_INPUT; a condition that uses one fails the run at that node.
  • Added (available on staging; in production since 2026-10-21): when a failure edge handles a node failure, the run context now also holds _lastNodeErrorCode (TIMEOUT, UNREACHABLE, RATE_LIMITED, REJECTED, UPSTREAM_ERROR, INVALID_DATA or UNKNOWN) and _lastNodeErrorPublic, a fixed neutral sentence for that code, next to the raw _lastNodeError. Branch on the code, and show end users only _lastNodeErrorPublic: _lastNodeError can name hosts, ids and the other service’s own message. Additive; existing graphs are unchanged.
  • Changed (available on staging; in production since 2026-10-21): a refused outbound URL (serverUrl on createMcpConnector/updateMcpConnector, url on createCustomAction/updateCustomAction, an agent’s externalSyncUrl, a webhook node’s url on updateWorkflow) now answers BAD_REQUEST instead of INTERNAL_SERVER_ERROR, with extensions.originalError.code OUTBOUND_URL_REFUSED and extensions.originalError.reason (INVALID_URL, UNRESOLVABLE_HOST or PRIVATE_ADDRESS); the message is unchanged. listMcpTools answers BAD_GATEWAY (extensions.status 502) when your MCP server cannot be reached or does not answer properly. See Refused URLs and an unreachable MCP server and Errors.
  • Added (available on staging; in production since 2026-10-21): POST /stt, /stt/google and /stt/gemini take an optional normalize: "numbers". English, Malay and Mandarin number words in the transcript become digits (empat lapan dua satu becomes 4821, lima puluh ringgit becomes RM50), and the response adds rawTranscript with the engine’s original text. Off by default; no model call; billing unchanged.
  • Changed (available on staging; in production since 2026-10-21): speech-to-text billing. A transcript is billed once even when the Cloud engine re-checked the clip (attempts: 2), a transcript from the Gemini engine is billed at the Gemini model’s token rate instead of per 15-second block, and the dashboard playground’s transcribeSpeech is now metered and counts toward the monthly spend cap. See Speech engines.
  • Changed (staging, on request; not in production): sdkStartVoiceCall has a per-API-key daily cap, 1,000 call requests per UTC day by default; past it the call is refused with extensions.status 429 and the message Daily voice call limit reached for this API key; try again tomorrow (UTC). A call nobody joins is now closed as EXPIRED about 10 minutes after the request (within about 15 minutes).
  • Docs (correction): Pricing said speech usage does not count toward the monthly spend cap. Speech through the speech service already counts, at its underlying cost; tool calls do not.
  • Docs: Why Wetel chose LiveKit, a sourced comparison of the platforms we looked at for real-time voice agents (no API change). Vendor facts were checked on 2026-10-19 and are linked; we did not benchmark any competitor.
  • Added (staging, on request; not in production): Telephony, live voice calls between a caller and a Wetel agent over LiveKit (WebRTC). The new mutation sdkStartVoiceCall(input: SdkStartVoiceCallInput!) takes the same named API key as sdkStart (server side only, with x-huat-platform: customer) and an agentId, an optional clientId and an optional flat context. It starts a session as sdkStart does and returns sessionId, agentId, livekitUrl, livekitToken, roomName and expiresAt; the token joins one fresh room and is accepted for about two minutes. The agent speaks first (saying it is an AI), can be interrupted, and answers with the agent’s published workflow. Errors: UNAUTHENTICATED (Missing X-Api-Key header, Invalid API key), FORBIDDEN (Voice calls are not enabled on this environment), NOT_FOUND for an unknown agent; rate limits of 60 a minute per client address and 30 call starts a minute per workspace (the over-limit error may carry no specific code). Read the limits: a Wetel person runs the voice worker for arranged test sessions (no always-on hosted worker yet), measured reply time is about 3 to 5 s against a target of about 1.5 s, the join token’s contents are readable by the caller’s browser, there are no call records yet, and SIP, WhatsApp calling and phone numbers are not available. Pricing is not set; usage is metered with the session. See Telephony, the quickstart, building a voice-safe workflow, pilot testing and the roadmap.
  • Changed (live): when a run pauses at an approval node, sessionEvents subscribers (SDK and guest chat clients) now receive an empty AiResponseEvent with turnComplete: true before the decision, so a client that waits for turnComplete is no longer held until a manager decides. Nothing is stored (no message, not replayed on reconnect); skip events with empty text. The outcome reply after the decision is delivered as before.
  • Changed (live): the pdf_export node also accepts sections JSON that an llm node wrapped in a markdown code fence or surrounded with prose, or that has raw line breaks inside its strings. The first complete value that parses and looks like sections is used, with the same rules and limits.
  • Added (live): the pdf_export workflow node. It turns a list of sections held in a run variable (an array, { sections }, or the same as JSON text from an llm node) into a PDF in one of three fixed layouts (brochure, itinerary, slides), stores it as an artifact and writes { artifactId } to its outputVar. No model call, no credits. Limits are strict, never truncating (brochure and itinerary: 40 sections, 4,000-character bodies, 30 items; slides: 30 sections, 1,200-character bodies, 8 items; 60,000 characters and 60 pages per document). A server renders one document at a time, so an export started while another renders fails at once: add a failure edge, which this node now supports (failure routing and the FAILURE_WITHOUT_SUCCESS/SUCCESS_WITHOUT_FAILURE diagnostics include it). A render or storage failure marks the artifact FAILED with a fixed code (RENDER_FAILED, TIMEOUT, TOO_LARGE, UPLOAD_FAILED, INTERNAL). Dashboard draft test runs create real artifacts, with kind pdf_document_draft.
  • Added (live): the Artifacts API for generated files. artifact(id) and artifacts(sessionId, kind, first, after) (newest first, first default 20, at most 100) read status PENDING/READY/FAILED, title, size and errorCode; the mutation artifactDownloadUrl(id) mints a signed link valid for 5 minutes (CONFLICT until the artifact is READY, NOT_FOUND outside your scope, 60 calls per minute per IP). Each takes a dashboard JWT (any artifact of the workspace) or the avatarToken from sdkStart (only that session’s artifacts). Treat the link as a credential: never store, log or share it.
  • Added (live): settings.notifyOnFailure on updateWorkflow, live in production since 2026-10-16. OWNERS gives every active workspace owner an in-app notice (read with getNotifications) when a run of that workflow fails, at most one per workflow per 60 minutes, the next one saying how many failures were held back; NONE (the default, also for a hired template) turns it off. Never for draft tests, regression replays, interrupted or superseded turns, or a failure a failure edge handled. The notice names the workflow and the failing node type and links to the run (/workflows/<id>/runs/<runId> in the dashboard); it never contains the error text or anything from the conversation.
  • Docs: Workspace preview walkthrough (preview, no API change). See Workspace Preview.
  • Added (live): a workflow version history API, all with a dashboard JWT. workflowVersions(workflowId, first, after) lists every publish, rollback or template hire that changed the published graph, newest first (Relay paging, first default 20, at most 100); workflowVersion(workflowId, versionNumber) returns one version’s full graph; workflowVersionDiff(workflowId, fromVersion, toVersion?) names the node and edge ids and node keys that changed (never values), and with toVersion omitted compares against the draft, which is exactly what the next publish would change. rollbackWorkflow(workflowId, versionNumber, note?) publishes an old graph again as a new version (source: ROLLBACK) and leaves the draft untouched; it is re-checked against today’s rules and refused with the same errors as publishWorkflow when it no longer passes, and with BAD_USER_INPUT when that graph is already the one live. restoreWorkflowVersionToDraft(workflowId, versionNumber) copies a version into the draft (replacing unpublished edits) without publishing. publishWorkflow(id, note) takes an optional note (at most 500 characters) stored on the version. The newest 100 versions are kept, plus the current one and any a waiting conversation uses. The reference now also says correctly that WorkflowDto.version goes up on every save (updateWorkflow and publishWorkflow) and starts at 1; it is not a version number.
  • Added (live): WorkflowDto.currentVersionId (the version new turns run) and WorkflowRunDto.workflowVersionId (the version a run executed; null for draft tests and older runs). Paused conversations already resume on the version they paused on in production (see the 2026-10-14 entry and Publishing while a conversation waits); a rollback behaves like a publish there.
  • Added (live): workflow settings with a custom failure reply. updateWorkflow(input: { settings: { failureResponse } }) sets plain text (1 to 500 characters after trimming) sent instead of the standard “Sorry, I ran into a problem” reply when a run of that workflow fails, including draft tests, resumed conversations and headless tasks (it shows in workflowTaskOutcome.replies). It is refused with BAD_USER_INPUT when it contains {{ }}, HTML tags, control characters or credential-shaped text; null clears it. It applies as soon as it is saved, is not part of any version, and is not sent for a failure handled by a failure edge or for an interrupted or superseded turn. Read it back with WorkflowDto.settings (never null).
  • Added (live): SessionDto.startedByUserId, the dashboard user who started a session with startSession; null for API-key (sdkStart), avatarToken, channel, sub-agent and replay starts and for older sessions, and always null on sessionsByExternalCustomerId. sessionActivitySummaries takes an opt-in mine: Boolean that keeps only your own sessions (totalCount included); a caller with no user behind the token gets an empty page. Without mine nothing changes.
  • Docs: 11 more operations in the developer portal catalogue, each with a curated example, example responses, its credential, the usual errors and samples in five languages (no API change, all live in production): evaluation on Sessions and messages; workflowRun and pendingAwaitReplies on Workflows; knowledgeDocument, resyncKnowledgeDocument and retrieveChunks on Knowledge bases; and two new pages, Scheduled workflows (createScheduledWorkflow, scheduledWorkflows) and Workspace and usage (myTenant, apiUsageSummary, customerUsageSummary). The Postman collection, OpenAPI file and interactive reference include them.
  • Changed ((live)): failure routing with success/failure edge labels now works on webhook, llm, sub_agent and set_variable nodes, not only tool and action. Opt-in by label: a node with neither label behaves exactly as before. Only an error raised by that node itself takes the failure edge (the trace row is FAILED_HANDLED, the run completes and the message is in _lastNodeError); a routing error, the run budget, the visit limit or an interrupt never does. A failure edge without a success edge is refused at publish. On an llm node a refused model (not in your workspace’s or API key’s allowed list, or not in the catalog) or a refused image now takes the failure branch when there is one, and the run completes. A sub_agent still reports a failed or timed-out target in its resultVar; its failure edge is for configuration errors only. On a router, success and failure stay ordinary route values.
  • Changed ((live)): workflowDiagnostics has a new INFO code, FAILURE_EDGE_ON_UNROUTABLE_NODE, for a success or failure label on a response, end_session, condition or switch node, where the label has no failure meaning. FAILURE_WITHOUT_SUCCESS and SUCCESS_WITHOUT_FAILURE now cover all six node types that route failures.
  • Changed ((live)): sub_agent inputs and outputs (listed under 2026-10-14) go to production with this release: named inputs rendered from the master’s context and set as variables in the sub-agent, named outputs returned as values (with missingValues for any that were absent or too large), reserved and underscore-prefixed names refused at save, and a bad input failing only its own target. The page now also notes that a duplicated output name and an input template that does not parse are refused at save.
  • Docs: the Sub-Agent Node page now warns that the conversation passed to a sub-agent includes the master’s own earlier replies (which a model can echo) and that [[...]] marker lines are not stripped from the text it sees. Verified in a live two-turn run of the ticket-specialist example on staging. No behaviour change.
  • Added ((live)): a sub_agent target can take inputs (named values, rendered from the master’s context, set as variables in the sub-agent before it starts) and outputs (names of sub-agent variables returned as values on that target’s result entry, with missingValues for any that were absent). Both are optional, so existing workflows are unchanged. A non-LLM node in a sub-agent no longer needs an extraction llm node to use a value from the master.
  • Docs: the Sub-Agent Node page now compares sub-agents with router branches in one workflow (which one keeps channel.raw, costs less, supports multi-turn, and gives per-specialist cost lines), and shows how to give a sub-agent the conversation, since a sub-agent starts with empty history. No behaviour change.
  • Docs: the Sub-Agent Node page now answers whether a sub-agent can run actions (yes), where an action should live depending on where its inputs come from, and what differs inside a sub-agent (no channel, no await_reply, no retry, timeout and depth limits). No behaviour change.
  • Docs: the Sub-Agent Node page now explains which nodes can use the text a sub-agent receives (llm) and which need a variable (condition, switch, router, and action/tool/webhook arguments), and shows how to avoid an extraction call by keeping those nodes in the master. No behaviour change.
  • Docs: the Read Channel Facts Without an LLM Call recipe now says up front which workflows have channel.raw (the one a message starts, not a sub-agent) and that set_variable is optional: read channel.raw directly, and add set_variable only for shorter names, a number or default, or a node that needs a flat variable name.
  • Docs: new cookbook recipe, Read Channel Facts Without an LLM Call: replace extraction llm nodes with one set_variable node that reads channel.raw (bracket syntax for dotted keys, a guard for a missing marker, and excludeHistory/matchAnyOf/modelOverride for the nodes that still need a model).
  • Changed ((live)): workflow runs record the published version they executed, and a paused conversation (await_reply or approval) resumes on the version it paused on, not on a graph published later. See Publishing while a conversation waits.
  • Fixed ((live)): sdkEndSession no longer fails when the evaluation model’s recommendation is longer than 50 characters; it returns n/a for the recommendation instead.
  • Docs (correction): the Sub-Agent Node page no longer suggests set_variable for pulling a value out of the sub-agent’s opening message. set_variable computes from existing variables and cannot split text; use the LLM node reading {{userMessage}} directly, or an extraction LLM node when a non-LLM node needs a variable.
  • Docs: the Sub-Agent Node page now states that channel (including channel.raw) and attachment are not available inside a sub-agent, and how to pass those values as text. No behaviour change.
  • Docs: the Sub-Agent Node page now explains how to use a master variable inside the sub-agent’s LLM node: put it in contextTemplate, then read {{userMessage}} in the sub-agent. No behaviour change. The Channels page also now shows how to read a dotted channel.raw key such as support.name ({{channel.raw.[support.name]}}).
  • Docs: a read-only Docs MCP Server for coding assistants that speak the Model Context Protocol. Seven tools answer from the developer portal’s generated files, offline: the catalogued operations with every input field, type and allowed value, the published samples in curl, TypeScript, PHP, C# and Python, a keyword search of the docs, the workflow node reference, and the JSON to Flow visualizer’s workflow checks with a code and a fix per finding. It never calls the API and never takes a credential. Not published to npm yet: run it from a checkout of the docs site repository. No API change.
  • Docs: a new Interactive API Reference shows the 41 operations of the Developer Portal catalogue on one page, rendered from the OpenAPI file: each operation listed as /graphql#<operationName>, its credential, variables with types and allowed values, example responses and request samples in curl, JavaScript, PHP, C# and Python. The Try it console is not switched on yet. No API change.
  • Changed ((live)): a workflow run’s trace now has one row per dispatch. When a node ran but routing then failed (a condition with no edge for its result, a switch with no matching edge, a router with no match and no default, an approval outcome with no edge, a labelled node with no success edge), that node’s row is FAILED and carries a new failurePhase (routing, or pause_persist when saving an await_reply pause failed) instead of a COMPLETED row followed by a FAILED row. The error text, the run status and NodeFailedEvent are unchanged. Runs stored before the change keep the two rows.
  • Fixed: createAgent now checks llmModelOverride and llmProviderOverride against your workspace’s allowed models, as updateAgent already did. Before, an agent could be created on a blocked model and was only refused at its next update. Workspaces with no model restriction see no change.
  • Changed: sessionsByExternalCustomerId returns at most 100 sessions per page (a larger first is clamped to 100, a value below 1 to 1). Page with hasNextPage / endCursor. Before, first: 0 could report hasNextPage: true with no cursor, and a negative value failed with a server error.
  • Fixed: for agents with useCase: CUSTOM, the evaluation returned by sdkEndSession now uses the agent’s own evaluationPromptTemplate and evaluationRecommendationLabels. Before, it used the generic good/fair/poor prompt, while the background evaluation after endSession already used yours. Built-in use cases are unchanged.
  • Docs: sdkEndSession evaluates before it returns (the evaluation is in the response); the troubleshooting and reference pages now say so.
  • Docs: the Developer Portal reference and the downloadable OpenAPI file now describe every field, input, argument and allowed value of the 41 catalogued operations (for example which sdkStart fields only apply when the workspace has no active agent). The same text appears in the GraphQL explorer on the sandbox. No API behaviour changed.
  • Docs (live): a new Developer Portal for developers who usually work with REST: a Hello ticket quickstart (start a session, send a message, poll for the reply with sessionsByExternalCustomerId, end the session) in curl, TypeScript, PHP, C# and Python; an Authentication cheat sheet with the exact message of every credential error; an Errors page with every extensions.code; and an operation reference for sessions, API keys and approvals with allowed values and example responses. A Postman collection, a Postman environment and an OpenAPI 3.1 file cover the same operations. Everything is generated from one set of curated examples that is validated against the API schema.
  • Docs (live): the examples now use the sandbox base URL, https://api.wetel.dev/graphql, stated once on Getting Started; your production base URL is issued when you go live. The speech service address is always the avatarServiceUrl returned by the API. Core API Flow also had two errors fixed: sdkEndSession’s evaluation selects score and summary (there is no rating or feedback field), and the session token lives about one hour, not 24. The x-huat-platform header is checked right after the credential, not before it, so a request missing both reports UNAUTHENTICATED first. No API change.
  • Docs (live): the Developer Portal operation reference now also covers agents, workflows (including runWorkflowTask, workflowTaskOutcome, workflowDiagnostics and nodeConfigSchemas), knowledge bases (with the three-step file upload), MCP connectors and custom actions, 41 operations in all, each in five languages and in the Postman and OpenAPI files. A short Route a ticket recipe lists the calls that turn the ticket triage cookbook into a running agent.
  • Docs: new end-to-end cookbook recipe, Triage a Support Ticket End to End: one complete graph (15 nodes, 19 edges) that collects a ticket with await_reply, classifies it with an llm node (matchAnyOf, streamOutput: false), normalises the values with set_variable (including coerce: "number"), branches with a switch, asks a workspace owner to approve urgent escalations with an approval node, replies and ends the session. It shows the run trace (assigned, matchedCase), a draft test with draftApprovalOutcome, and what each workflowDiagnostics code would mean for that graph. The graph passes the platform’s save and publish checks and can be pasted into the JSON to Flow visualizer or the editor’s Import dialog.
  • Added (live in production since 2026-10-13): a workflow run trace id. WorkflowRunDto.traceId (a UUID, null for older runs) is shared by every run of one logical execution: a new id per ordinary turn, the paused run’s id when a run continues an await_reply or approval pause, and the parent’s id for a sub_agent child run. The same value is sent as the x-wetel-trace-id request header on the run’s outbound webhook, action and MCP tool calls, so a request in your logs can be matched to the run. A header of the same name that you configure yourself is kept. Additive: existing queries, graphs and callers need no change, and a server that ignores unknown headers is unaffected. It is never shown to end users.
  • Added (live in production since 2026-10-11): approvals takes an optional statuses: [ApprovalStatus!] argument that lists requests in any of several states in one newest-first list, for example every decided request with statuses: [APPROVED, REJECTED, EXPIRED, CANCELLED]. Paging (first, after) is unchanged. Sending both status and statuses, or an empty statuses list, answers BAD_REQUEST; duplicates are ignored. With neither argument the inbox still lists only PENDING requests, so existing callers need no change.
  • Added (live in production since 2026-10-11): a switch workflow node: 1 to 10 cases, each a label and an expression in the condition node’s expression language, tested in order. The first true case routes along the edge labelled with its label; when none is true the run follows the edge labelled default. Publishing requires exactly one edge per case label and exactly one default edge, and refuses any other edge label (except loop_exhausted) and reserved case labels. A case that names a variable the run does not hold counts as false, like a condition. The trace row records matchedCase, matchedCaseIndex and switchUnresolved. nodeConfigSchemas returns 13 entries. See Switch, router or condition? for which branching node to use.
  • Changed (live in production since 2026-10-11): workflowDiagnostics no longer reports LLM_EXTRACTION_REGEX_HELPER. It was an INFO note that said “no action needed” whenever an llm node used matchAnyOf, extractFirstInteger or extractAlphanumericId correctly. LLM_HELPER_IGNORED_WHEN_STREAMING, the real warning for those helpers, is unchanged. If your tooling switches on codes, treat an unseen code as a generic finding, as before.
  • Changed (live in production since 2026-10-11): the JSON to Flow visualizer now draws every node type, including await_reply, approval and set_variable, with a type chip, a colour-coded border, a one-line summary per node, labelled and coloured edges, and a dotted default edge for a router’s defaultTarget. Its Findings panel runs the same checks as workflowDiagnostics. The workflow editor in the Wetel app shows the same look, a set_variable palette entry and form, and a Checks strip with the draft’s findings.
  • Added (live in production since 2026-10-11): a set_variable workflow node that writes context variables without calling a language model. Each of its 1 to 20 assignments is an expression (the condition node’s expression language), a template (Handlebars text) or a literal, with optional coerce (number, boolean, string, json) and, for expressions, a default used when a referenced variable is not set (without one, that case fails the run, unlike a condition). coerce: "number" stores a real number, so a later condition compares numbers rather than text. It replaces an llm node used only to copy or convert a value. The run trace row lists the variables it wrote (assigned).
  • Added (live in production since 2026-10-11): workflowDiagnostics(id, published), a read-only query that lists non-blocking findings about a workflow graph: a router with no fallback, a router edge that is never followed, a dotted router variable that reads into LLM text, a condition missing a branch, an extraction option a streaming llm node ignores, two parallel paths meeting at one node with no join, unreachable nodes and more. Each finding has a stable code, a WARNING/INFO severity and a message naming the node and what to do. It checks the draft by default (published: true checks the published snapshot) and never blocks saving or publishing. See Workflow Diagnostics and the API reference.
  • Changed (behaviour, live in production since 2026-10-10): a router whose value matches no outgoing edge label and that has no defaultTarget (and no tripped circuit breaker) now fails the run at that node: a FAILED trace row whose error names the variable, the value and the available labels, a NodeFailedEvent, and the standard failure reply. Before, the run ended with no reply and no error. A router with a defaultTarget behaves as before. Set a defaultTarget on every router.
  • Added (live in production since 2026-10-10): a router’s variable can be a dotted path into an object in the run context, for example analysis.severity when analysis holds an object. If the first part of the path holds plain text (an llm node stores its answer as text, and the platform does not parse it), the router cannot read inside it: the node’s trace row now carries a routerNote explaining that, so write the value to its own variable with a separate step instead.
  • Added (live in production since 2026-10-09): partnerAgentTemplates returns each slot’s slotKey, so you know which key to pass in slotBindings or partnerBindAgentTemplateSlot before hiring. The workspace gallery still returns null for it. See Hiring role templates with an API key.
  • Added (live in production since 2026-10-09): partners can hire role templates with an API key. createNamedApiKey takes optional scopes (templates:hire, owner only); with that scope a key can call partnerAgentTemplates, partnerHireAgentTemplate (synchronous, idempotent, 200 active roles and 50 hires per day per key by default), partnerAgentTemplateInstall(s) and partnerBindAgentTemplateSlot, and sees only the installs it made itself. slotBindings on the hire binds shared custom actions, MCP connectors and knowledge bases instead of creating new ones. Templates have an audience (gallery, partner or both) that Wetel sets. See Hiring role templates with an API key.
  • Docs: the AI Coding Assistant Skills file now describes Wetel as an enterprise AI workforce platform and adds the rules the live docs depend on (number gates with > 0, streamOutput: false for extraction, approval as a step you add, draft tests and secrets in templates, what an API key can do) plus guidance for the assistant itself: never print keys, ask before publishing, and do not claim capabilities the docs list as not built. If you copied the older skill, replace it.
  • Docs (correction): the skill file listed 11 workflow node types without approval and said an API key only starts sessions; it now matches Authentication. The Workbench page now says the platform header is required. Troubleshooting said a secret in a rendered request shows “unredacted”; recognisable secret shapes are masked on a best-effort basis, but a raw secret in a template can still appear. The Agents page lists all five voiceTier values.
  • Docs (correction): an API key cannot create or change agents or workflows (those need a dashboard login); Multi-Tenancy said a key grants full access to agents and workflows, and the Authentication credentials table said a key only starts sessions. Both now describe the runtime operations a key can run (starting sessions, workflow tasks, model and embedding calls). The generateApiKey curl example sent an API key as the bearer token; the mutation needs a login access token. The Admin API panel guide said usage is billed through Wetel; Wetel reports usage and does not bill it yet.
  • Docs (correction): the order-lookup extract_order_id step in Worked examples set extractFirstInteger without streamOutput: false. An llm node streams by default and a streaming node ignores the extraction options, so the order number was never extracted and the orderId > 0 check could not pass. The example now sets streamOutput: false. If you copied it, add that line to your extraction step.
  • Docs (correction): the order-lookup example in Worked examples gated on orderId != '0'. Since 2026-09-11 extractFirstInteger stores the number 0 when nothing is found, and a condition does not coerce types, so that gate let a “no order number” message through to the lookup. It now reads orderId > 0. If you copied the old expression, change it. The order-support recipe no longer calls the extracted value a string.
  • Docs (correction): Pricing said the monthly spend cap covers only the direct language-model API. It is also checked before each chat turn, workflow task (scheduled runs included) and playground call. It is a check made before work starts, so work already under way can carry spend past it, and speech and tool-call usage does not count toward it. The wording “exact” for speech and tool-call credits now reads “calculated from fixed per-unit rates”, because credits are a usage figure and not an invoice.
  • Docs (correction): the Pricing, Multi-Tenancy and Core Concepts pages said or implied that a dedicated database per workspace is available. It is designed but not currently offered; workspaces share the platform’s database and every request is scoped to the signed-in workspace. The Voice Messages page listed the old four-language fallback set; the default is English and Malay (changed 2026-09-29). The Roadmap no longer lists hiring a role yourself as future work, since owners can already do it (2026-10-04).
  • Added (live in production since 2026-10-09): runWorkflowTask accepts an optional attachments list (up to 10 files, the same shape as on sdkSendMessage) next to the single attachmentUrl fields; sending both is a 400. Changed (stricter): the single attachmentUrl/attachmentMime/attachmentFilename fields now go through the same validation as attachments: attachmentMime must be a valid type/subtype (it is lowercased; a malformed one answers 400 attachmentMime is not a valid MIME type), the URL must not carry user:password@, and the filename loses quotes, brackets and control characters. Regression scenarios now capture and replay structured attachments and turnContext as structured input (no marker is written into the text).
  • Added (live in production since 2026-10-09): sessionEvents(sessionId, sinceMessageId). A reply published while your websocket was closed is no longer lost: pass the id of the last model message you processed (0 on the first subscribe) and the server first replays the stored replies you missed (newest 20, oldest first), then continues live, delivering each reply once. AiResponseEvent gains messageId. Omit the argument for the old live-only behaviour. See Reconnecting without losing a reply.
  • Added (live in production since 2026-10-09): messages now store their structured attachments. The message attachments field returns each attachment with its URL’s query string and credentials removed (?[redacted]), and imageMarkerHistory on an llm node reads structured IMAGE attachments from earlier user messages as well as [image:] markers. Changed: in a run’s context (workflowRuns), attachments[].url, attachmentUrl and attachmentImageUrls are now returned with the query string removed, so a signed link can no longer be copied back out of the run view; the stored value is unchanged and nodes still fetch the full URL. See Sending files and context.
  • Added (live in production since 2026-10-09): structured attachments and context on the SDK. sdkSendMessage and sendMessage take optional attachments (up to 10 files: url, mime, filename, kind) and turnContext, and sdkStart takes an optional context. Workflows see them as attachments, attachmentImageUrls (usable directly as an llm node’s imageUrlsVar), turn.context and session.context. Empty message text is allowed when an attachment is present. Text markers keep working and are overridden by structured attachments. attachments, attachmentImageUrls and turn are now reserved workflow context names. See Sending files and context.
  • Added: WorkflowRun.continuedByRunId: on a run that paused at an await_reply or approval node, the id of the run that continued it (the inverse of resumedFromRunId). It is null while the run is genuinely still waiting, so a dashboard can tell a waiting run from one that was already continued; the paused run’s status stays AWAITING_INPUT. Returned by workflowRun and workflowRuns.
  • Changed, live in production: the Gemini speech model behind POST /stt (and POST /stt/gemini) is now gemini-3.5-flash instead of the dedicated gemini-3.5-transcribe, which Google caps at 100 requests a day per project. No request or response change. Transcript wording can differ slightly for the same audio. In a 48-hour test on staging, 99.6% of 1,382 clips succeeded, 1 fell back to Cloud Speech-to-Text, none came back empty, the median call took 1.65 s and the 95th percentile 3.2 s.
  • Changed: a “not found” error now answers extensions.code = NOT_FOUND and a conflict answers CONFLICT (both used to arrive as INTERNAL_SERVER_ERROR, with the real HTTP status only in extensions.status, which is still there). The message is unchanged. BAD_REQUEST, UNAUTHENTICATED and FORBIDDEN are unchanged, and rate limits (429) and server errors keep INTERNAL_SERVER_ERROR. If your client treats INTERNAL_SERVER_ERROR as retryable, a missing record or a conflict is no longer retried, which is what you want. See Error handling.
  • Added: the approval workflow node and the approvals API: a workflow can pause until a workspace owner approves or rejects a step, then continue down the approved, rejected or expired edge. New operations approvals, approval, decideApproval, cancelApproval and the subscriptions approvalOpened/approvalDecided (owner-only), plus draftApprovalOutcome on sendMessage for draft tests. Email notification is not available yet. Step by step: Human approval: a walkthrough.
  • Changed: sdkSendMessage’s and sendMessage’s text limit raised from 10,000 to 100,000 characters (still rejected, not truncated, with text must be shorter than or equal to 100000 characters). The model sees only as much history as fits its token budget, so very long messages leave less room for earlier turns. If your own code also caps the text you pass along (for example a prompt-building step with its own limit), raise that too.
  • Changed: input-validation errors now say what is wrong. The response is still HTTP 200 with extensions.code = BAD_REQUEST and extensions.originalError.message as the array of constraint messages, but the top-level errors[0].message is those messages joined with ; (for example name should not be empty) instead of Bad Request Exception, on the main API and the portal API. Template keys must be 3 to 64 characters of lowercase letters, digits and dashes, starting and ending with a letter or digit (pattern ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$, for example partner-image-reader); a dot is refused with a message naming the pattern. See Troubleshooting.
  • Added: createNamedApiKey also returns the key’s id and name (the key itself is still shown once); pass the id to revokeNamedApiKey to revoke it. See Authentication.
  • Changed: workflow node type values must be lowercase (start, llm, response, …); an uppercase one is refused at save and publish with BAD_USER_INPUT (node n1: type "START" must be lowercase: "start"), and publishing a graph with no start node lists the node types it saw. Previously an uppercase type was only caught at execution.
  • Fixed: setting llmModelOverride on an agent without llmProviderOverride now runs the agent on the provider that owns that model (a Gemini model name with the default provider used to fail every image node). createAgent and updateAgent refuse a model no enabled provider serves, and a provider that contradicts the model, with BAD_REQUEST and a message that lists the allowed choices. See Agents.
  • Docs: the LLM node page now says matchAnyOf replaces the output with the matched label, so it is only for single-label classification nodes, and how to handle JSON wrapped in a markdown code fence. The photo-reading recipe notes that the SDK credential cannot read workflowRuns or the serving model.
  • Changed: invalid llm image options are now refused with BAD_USER_INPUT (was INTERNAL_SERVER_ERROR) when you call updateWorkflow or publishWorkflow, with a message naming the node id, the field and the allowed range. imageMarkerHistory must be an integer from 1 to 40, imageUrlsVar a non-empty string, imageUrlsFailPolicy skip or fail, and imageVars an array of non-empty strings. See LLM node.

  • Changed: signed URLs in [image:<url>] markers are still stored exactly as you send them (the node needs them to fetch the image), but when the message text is read back through the API (session messages, workflowRuns context, the portal) the marker is shown as [image:https://host/path?[redacted]]: scheme, host and path are kept, userinfo, query and fragment are removed. Text outside a marker is not rewritten, and your own conversation-sync integration still receives the original text.

  • Added: Recipe: Read Many Photos in One Node, a live-verified step-by-step walkthrough of the llm node URL image options: requests and responses, the imageFetch trace, limits, a migration note and a troubleshooting table.

  • Fixed (docs): voiceTier is optional on createAgent (it defaults to STANDARD), not required. A missing x-huat-platform header on a real operation answers HTTP 200 with extensions.code = FORBIDDEN, not an HTTP 403. The docs now say which credential each management call takes (a dashboard JWT for createAgent, publishWorkflow and workflowRuns; the API key for sdkStart and runWorkflowTask), that runWorkflowTask variables must be flat scalars (send an imageUrlsVar list as a JSON string), and that an out-of-range imageMarkerHistory was then reported as INTERNAL_SERVER_ERROR (now BAD_USER_INPUT, see above).

  • Changed: the URL image options (imageUrlsVar, imageMarkerHistory, imageUrlsFailPolicy), server-side image shrinking and the 40-image default are now live on production as well as staging. After tuning, the shared fetch deadline for the whole fetch-and-shrink phase is 90 seconds (was 30) and each server shrinks three images at a time (was two). Measured with 40 large phone photos (about 253 MB of originals, 12.6 MB after shrinking): alone, 40 of 40 attached in about 54 s; 2 overlapping runs attached 40 and 40 in about 113 s each; 4 overlapping runs attached 38 to 40 in 109 to 129 s each. With the old 30 s and two at a time, 2 overlapping runs attached only 23 and 25 of 40. See LLM node.

  • Changed: llm node images fetched by URL are now shrunk for you instead of skipped when large. Originals up to 20 MB are fetched, auto-oriented and shrunk to a long edge of about 1568 px (typically 200 to 600 KB each; an image already within the limits is passed through untouched), so imageUrlsVar and imageMarkerHistory now take up to 40 images per node by default (imageMarkerHistory 1 to 40). imageVars keeps its cap of 20. Originals over 20 MB are skipped (too-large) and so are images over 36 megapixels (too-many-pixels). After shrinking, the 5 MB per image (about 3.9 MB on the Claude route) and 18 MB per call limits apply, or 24 MB when the node pins a Gemini model. The fetch deadline is now 90 seconds (see the first entry of this day), and a run with 40 large photos can take about a minute or more, so keep imageMarkerHistory as small as the question needs. The earlier advice to resize images before sending them is no longer required.

  • Fixed: per-IP rate limits now count each caller’s own address. The platform used to see a Cloudflare edge address for every request, so callers that happened to share an edge could share a count, and a limit such as the 5-per-minute sign-in limit was not really per caller. Limits are unchanged; only whose requests are counted together changed. Callers behind one office network or one server still share a bucket. Per-tenant ceilings are unaffected.

  • Added: an llm node can fetch images by URL itself. imageUrlsVar names a context variable holding one or more image URLs, and imageMarkerHistory attaches the images named by the last N [image:<url>] markers in the user’s messages. Until now each image needed its own fetch step (action with fetchAsBase64Var) feeding imageVars; the URL options replace that, and the image bytes are never stored in the workflow context or run record. Only https:// and s3:// (granted buckets) URLs, no redirects, no private addresses; each image must be a real JPEG/PNG/GIF/WEBP of at most 5 MB (about 3.9 MB on Claude models through the default provider), a 90-second deadline for the fetch (30 seconds when this was first released), and 18 MB per node in total. Update, same day: large images are now shrunk instead of skipped, and the limit is 40 images (see the entry above). An image that cannot be loaded is skipped and the model is told ([note: 1 of 3 images could not be loaded]); imageUrlsFailPolicy: "fail" fails the node instead. The node trace records counts, sizes, hosts and reasons, never the URLs. Existing imageVars graphs are unchanged.

  • Fixed: the per-address rate limits on the speech routes (/stt, /stt/google, /stt/gemini, /tts, /tts/google, /tts/gemini) counted every caller on the platform together, because the service saw only its reverse proxy’s address. One integration’s burst of voice notes could return 429 to unrelated customers. The service now uses the caller’s address, and the defaults are 120 requests a minute for speech-to-text and 300 for text-to-speech per address (were 10 and 30); the per-tenant ceilings (200 and 600) are unchanged and are the ones to plan around. See Avatar Service REST API: rate limiting.
  • Added: speechToken returns a credential for speech-to-text and text-to-speech (/stt, /tts) without starting a session. Before this, the only way to get a token for those routes was sdkStart, which creates a metered session and is limited to 30 starts a minute per client IP. Works with a dashboard JWT or an X-Api-Key. The token is tenant-scoped and rejected by every session operation. See the Speech-to-Text and Text-to-Speech Integration Guide.
  • Fixed: the avatarToken query has been failing since tokens were bound to sessions (2026-09-08). It now returns the same speech token as speechToken and is deprecated in favour of it.
  • Fixed: POST /stt/google with a LINEAR16 WAV file that was not 16 kHz (Gemini text-to-speech output is 24 kHz, most phones record 44.1 or 48 kHz) returned 500. The sample rate is now read from the WAV header, as it already was for OGG_OPUS. For a WAV the header wins over an explicit sampleRateHertz; for raw PCM the explicit value is still used.
  • Changed: a request the speech provider rejects as invalid (audio that does not match its declared encoding or sample rate, input too long) now returns 400 instead of 500 on every /stt route, so you can tell your request from a provider outage. Do not retry a 400 unchanged.
  • Changed: an llm node’s imageVars now accepts up to 20 images per call, up from 4. Each image is still under about 5 MB and all images on one call under 18 MB combined. Entries beyond 20 are dropped, not rejected. A workflow that listed more than 4 imageVars used to have the extras ignored and now sends them, so it uses more of the shared model quota.
  • Added: a Wetel administrator can now restrict a single API key to a list of models, on top of the tenant-wide list. A key can only narrow what the tenant allows; sessions started with the key keep the restriction for every later turn, workflow LLM node and sub-agent. Set on request; documented 2026-10-06.
  • Added: workspace owners can hire a role themselves. agentTemplates lists the roles Wetel has made available, hireAgentTemplate hires one into your workspace (owner only, PRO plan for now, 10 roles at once and 5 hires per day), and provideAgentTemplateSlotCredential finishes a waiting setup step by giving its credential. Credentials are write-only. Nothing appears in the gallery until Wetel lists a role, so this changes nothing for existing integrations.
  • Added: every user now has a tenantRole (OWNER or MEMBER) inside their workspace, readable on the user object. It is set from WebbyX One’s company ownership at sign-in, or to OWNER for the creator of a workspace, and is what the owner-only operations above check.
  • Added: agentActivitySummary, a per-AI-employee outcome roll-up for supervision dashboards: runs by status, recovered failures, average evaluation score, open questions waiting on a person, schedule health and a needsAttention flag, for the whole tenant or one agent over a preset or custom window (up to 92 days). JWT only, 30 requests per minute per client IP. creditsUsed and scheduleFires are null for now.
  • Fixed: the opening greeting, the direct (non-workflow) streamed reply and the evaluation run when a session is ended through sdkEndSession were never counted in your usage totals or against the monthly spend cap. They are now, so apiUsageSummary and the cap can read slightly higher than before for tenants using those paths.
  • Fixed: timeoutMs on action, webhook, tool and sub_agent nodes (and the media fetch) now has a floor as well as a ceiling. 0, a negative number or a non-number used to disable the timeout on some nodes; it now means “unset” and the default applies, and a value below 1000 is raised to 1000. tool nodes now also enforce the 30-second ceiling their docs always stated.
  • Docs fix: expiresIn and refreshExpiresIn on the login responses are absolute Unix epoch seconds, not durations. The example responses showed duration-like values; they now show epochs.
  • Changed: a knowledge-base document that would exceed your plan’s chunk limit is now rejected before it is embedded, so a rejected upload no longer consumes embedding usage. confirmKnowledgeDocumentUpload is now rate limited to 10 requests per minute per client IP, the same as requestKnowledgeDocumentUpload.
  • Changed: isRegisterable no longer checks national identity numbers (it could confirm that a given identity number was registered, to anyone). The idNo argument is deprecated and a request that sends it is now rejected; the response is { "email": boolean }. Nothing that sends only email is affected.
  • Changed: when a tenant is suspended, new work is now refused on every path, not only for dashboard sign-ins. sdkStart, sdkSendMessage, sendMessage/startSession, the LLM API (generateText, startTextGeneration), runWorkflowTask and scheduled workflow fires all return 403 (Tenant #N is suspended.), and scheduled workflows resume on their own once the suspension is lifted. Ending a session is still allowed so in-flight conversations can close.
  • Added: agents have a timezone (IANA, default Asia/Kuala_Lumpur), and workflow templates have {{today}} and {{now}} helpers that resolve in it, daylight saving included. See Today’s date and the current time. agentTimezone is now a reserved workflow variable name.
  • Added: workflowTaskOutcome(sessionId) reads back the result of a runWorkflowTask call with the same API key: the newest run’s status, the runs, a masked error for a failed run, and the agent’s replies. Until now the only way to read a task’s outcome needed a dashboard login. It never returns the run context or node trace, and only sessions a task was accepted for are readable.
  • Changed: a session that has had no activity for 30 days is now moved from ACTIVE to EXPIRED (previously abandoned sessions stayed ACTIVE forever, which inflated active-session counts and usage figures). No evaluation is run and no event is sent. runWorkflowTask, sdkStart and the channel gateways already start a fresh session when no ACTIVE one matches, so a conversation that returns later is unaffected; a stale avatarToken for an expired session gets the existing “session is not active” error.
  • Added: testCustomAction makes one real call of a custom action through the same path a workflow uses and classifies the result (CREDENTIAL_REJECTED for a 401/403, REQUEST_REJECTED, SERVER_ERROR, TIMEOUT, …), so a wrong URL or credential is found before a live conversation hits it. A non-GET action needs confirmWrite. A successful test is billed like the same call in a workflow; a failed or refused test is free.
  • Changed: agentConfigOptions.recommendedTtsVoices now lists 24 voices (was 6): en-US across all five tiers (Standard, WaveNet, Neural2, Chirp3 HD, Studio), en-GB, ms-MY and Mandarin (cmn-CN), each name checked against Google’s own voice list. Each voice has a new voiceTier field, the value to send as the agent’s voiceTier, so you can offer only the voices that fit the chosen tier. Existing queries are unaffected.
  • Fixed: a workflow node can no longer use session, attachment, attachmentUrl, attachmentMime, attachmentFilename, channel or clientTurnId as its outputVar, saveAs or similar variable name; saving such a workflow now fails with a “reserved workflow context key” error. A WEBHOOK node’s optional outputVar is checked too. Rename the variable if you hit it.
  • Added, live in production: agents that Wetel sets up for you from a role template now come with a setup checklist. agentTemplateInstall shows what is left to finish, and bindAgentTemplateSlot finishes a slot with a resource you created yourself (a custom action with your own token, or your own MCP connector). When the last required slot is bound the agent turns on. Your credentials are never handed to Wetel.
  • Behaviour change for those agents only: an agent that is still being set up refuses sdkStart and channel connections with a Forbidden error that says so. Agents you created yourself are unaffected.
  • Added, live in production: an administrator can now restrict a tenant to a list of LLM models (for example Claude Haiku only). The llmModelCatalog query returns only the allowed models, myTenant { allowedLlmModels } shows the list (null means no restriction, the default), and a blocked model is refused with a clear error on generateText, the playground and llm nodes, and when saving an agent or publishing a workflow. Nothing changes for any tenant until a list is set. See Restricting which models a tenant can use.
  • Fixed, live in production: requests with a body over about 1 MB (for example an LLM request carrying a large base64 image) were rejected with HTTP 413 by the gateway, which browsers showed as a CORS error. The limit is now 10 MB. See the LLM API request size note.
  • Docs (new page): Choosing Languages and Voices explains primary vs alternative speech-to-text languages, the three-alternative limit, the speech tiers, and how a voice name relates to language and tier. It links to Google’s own language and voice lists as the source of truth. Also corrected the /stt page, which said Mandarin and Cantonese “will be removed” from the default fallback set; they have not been in it since 2026-10-01.
  • Changed, live in production: POST /stt now transcribes with Gemini by default and falls back to Cloud Speech-to-Text on any error. Expect confidence: 0 (“not reported”) and your languageCode echoed back when Gemini answers. POST /stt/google still gives the previous behaviour exactly.
  • Added, live in production: the WEBBYXAI_HAIKU provider can serve Claude Sonnet 4.6 and Claude Opus 4.6 (with -low, -medium, -high and -max effort variants) through modelOverride, llmModelOverride and generateText. Same per-token prices as Anthropic’s list (Sonnet $3 / $15, Opus $5 / $25 per million tokens in / out). They share the same model quota as Haiku, so use them sparingly.
  • Added, live in production since 2026-10-01: the default model provider (Claude Haiku) and the other Claude models it serves now read images. An llm node’s imageVars works on a Haiku agent with no provider change, up to 100 images per call (each under 5 MB as base64). The per-node limit stays 4 by default and the platform operator can raise it to 20, with 18 MB combined. Gemini is still available per node through modelOverride and answers automatically if an image is refused. On a quick digit-reading check Haiku scored 9 of 10 and Gemini 10 of 10.
  • Added, live in production since 2026-10-01: POST /tts/gemini, an optional Gemini text-to-speech route for voice notes. WAV output, no word timings (not for lip-sync), about 3 to 4 seconds per reply. /tts and /tts/google are unchanged.
  • Behaviour change, live in production since 2026-10-01: end_session’s runEvaluation is now honoured, and leaving it out means no evaluation. It was always documented as an opt-in but the platform ignored it, so every end_session evaluated and a node that said runEvaluation: false still did. Both false and an omitted field are live. If your workflow omits the field and you read its evaluations, add "runEvaluation": true. Sessions ended through sdkEndSession still return their evaluation. Chat flows that end a session after each exchange save up to two model calls per session.
  • Added, live in production since 2026-10-01: POST /stt/gemini and POST /stt/google let you pick the speech-to-text engine per call. /stt/gemini uses a Gemini speech model: faster in our checks, capitalised and punctuated text, no language hint needed. confidence is 0 (“not reported”) and the response languageCode echoes your request. Plain POST /stt is unchanged and keeps using Cloud Speech-to-Text unless the operator changes the default.
  • Added, live in production since 2026-10-01: an llm node’s modelOverride can now name a model another provider serves, and that one node runs on that provider. Use it to give a single image node a Gemini model inside an agent that otherwise runs on Haiku. Before, naming another provider’s model failed the node. A model no provider serves still fails it.
  • Added, live in production since 2026-10-01: runWorkflowTask and scheduled workflow fires now leave an invocation record (agent, source, your externalConversationId, the names of the variables sent, accepted or rejected with a fixed reason code, and a link to the resulting run) so support can trace what an integration fired. Variable values, attachment details and error text are never stored. No change to the mutation’s arguments or result, and nothing for you to do. If you put something sensitive in externalConversationId, stop: it is now stored as sent.
  • Added, live in production since 2026-10-01: myAuditLogs, the signed-in user’s own recorded actions in their workspace, newest first, with cursor paging and optional auditableType and action filters. Only field names are recorded, never values, and you only see your own actions. Recording now covers agents, API keys, workflows (including publish), custom actions, MCP connectors, channel connectors, scheduled workflows, knowledge bases and workspace settings; before, only agent updates were recorded. Additive: existing queries are unaffected.
  • Behaviour change, live in production since 2026-10-01: agentAuditLogs no longer returns values for credential-like agent fields. externalSyncUrl, externalSyncSecretRef, externalSyncOwnerId, externalSyncSchemaConfig and avatarBackendConfig come back as { from: null, to: null }, including in older entries, and are no longer stored. Prompts and other ordinary settings still show their old and new values. The history also now includes the agent’s creation and deletion, which carry no field values.
  • Added, live in production since 2026-10-01: apiUsageSummary breakdown rows gain creditsUsed: Int!, the credits each (model, operation) item consumed in the selected month. It uses the same rule as the item’s bucket and the rows in a bucket sum exactly to that bucket’s creditsUsed (LLM credits are approximate; tool calls, speech and avatar are real charges). costUsd is unchanged and still returned. Additive, so existing queries keep working; select creditsUsed only once your target environment has this release, since an unknown field fails the whole query.
  • Added, live in production since 2026-10-01: sessionActivitySummaries can now be sorted on the server. New optional arguments sortBy (CREATED_AT, STARTED_AT, ENDED_AT, STATUS, CLIENT_EXTERNAL_ID, AGENT_NAME, EVALUATION_SCORE) and sortDirection (ASC, DESC). Rows with no value for the sort field come last in both directions, and pagination cursors are tied to the sort they were issued under. Without the new arguments nothing changes: newest first, and existing cursors keep working. Last-message time and latest-run status can’t be sorted yet.
  • Behaviour change + added, live in production since 2026-10-01: apiUsageSummary breakdown is now scoped to one calendar month. It used to count every call since the tenant was created, even though the dashboard labelled it “this month”. It also no longer truncates model names that contain a colon. New optional argument yearMonth: String (YYYY-MM, past or current month) scopes every figure in the response to that month, so you can look at previous months. New fields: yearMonth on the summary and costUsd on each breakdown row. Existing field names are unchanged. If you were reading breakdown as lifetime totals, sum it across months yourself.
  • Live in production since 2026-10-01: Per-agent speech-to-text languages. Agent, createAgent and updateAgent gain sttLanguageCode (the STT primary; null = use ttsLang) and sttAlternativeLanguageCodes (null = platform default English + Malay, [] = primary only, otherwise at most 3 entries, no duplicates, not containing the primary, BCP-47 shaped). Sending null on updateAgent resets a field to its default. Telegram voice notes now honour both fields. To bring Mandarin detection back for an agent, set sttAlternativeLanguageCodes: ["cmn-Hans-CN"].
  • Live in production since 2026-10-01: sdkStart and startAvatarDemoSession return two new fields, sttLanguageCode: String! (already resolved: agent setting, else ttsLang, else en-US) and sttAlternativeLanguageCodes: [String!]. Both are additive. <vai-avatar> gains matching stt-lang and stt-alternative-langs attributes.
  • Behaviour change, live in production since 2026-10-01: <vai-avatar> used to always send en-US as the STT primary. When stt-lang is not set it now follows tts-lang. Pages that set neither attribute are unaffected. Set stt-lang explicitly if your agent speaks one language but callers speak another.
  • Live in production since 2026-10-01: POST /stt now carries a fallback re-check guard: when a fallback language wins the recognition but produces an empty transcript or mixed-script garbage (a small Han fragment plus English text), short clips (under ~50 seconds) are automatically re-checked once in the primary language only. If the primary-only result is at least as good, it replaces the fallback’s result. The response now includes attempts: 1 or attempts: 2 so you can meter this — each recognition call is billed per 15-second audio block. Language reporting is now standardised: unrequested Chinese winners are reported in BCP-47 casing (cmn-Hans-CN, yue-Hant-HK) instead of all-lowercase; and requested Chinese languages are reported the way you spelled them in the request (so zh-CN comes back as zh-CN, not normalized to cmn-Hans-CN or anything else). Known limits: (1) Cantonese on a Mandarin primary counts as the primary and is never retried. (2) Japanese fallbacks are re-checked only for empty transcripts, not mixed-script. (3) Genuine code-switching (Mandarin with English phrases woven in) can trigger a re-check but the original is kept unless the primary-only result is at least as confident. alternativeLanguageCodes is now protected: any Chinese variant (zh, zh-CN, zh-TW, zh-HK, cmn-*, yue-*) in the list is guarded by the re-check, even if your primary is not Chinese.
  • Already live: The default fallback language set for POST /stt changed from [en-US, cmn-Hans-CN, ms-MY, yue-Hant-HK] to [en-US, ms-MY] only (shipping 2026-09-29, now live in production and staging). Mandarin and Cantonese were removed because they trigger false-positive language misclassifications on accented English clips — even fluent English like "Hi testing one two three" the provider sometimes tags as cmn-Hans-CN or yue-Hant-HK. The language-tag correction (fall back to non-Han when the winning language’s script doesn’t match its transcript) still exists, but removing them from the defaults prevents the mislabel entirely. If your integration needs Chinese detection, pass it explicitly: "alternativeLanguageCodes": ["cmn-Hans-CN"] or ["yue-Hant-HK"] (or both, using up to 3 slots). The same changes apply to the LLM Playground’s transcribeSpeech query.
  • Already live, undocumented: POST /stt’s OGG_OPUS encoding now auto-detects sample rate from the file header itself — the sampleRateHertz field is now genuinely optional for OGG/Opus files. Different source apps (Telegram, WhatsApp, etc.) record at different real rates, and guessing one value could never be right for every source. If you were passing sampleRateHertz explicitly as a workaround for empty-transcript results on some sources, you can now leave it unset and let the provider read the real rate from the file.
  • Added: Lark joins OmniChat/Nortia/aisCRM as a certified-operation product — certifiedOperations(product: "LARK"). Unlike the other three (Wetel’s own shared house accounts), Lark is a per-tenant app integration — bring your own Lark app (larkAppId/larkAppSecret, already self-serve via createCustomAction, see Using Lark) and certified Lark operations work against it directly, no WebbyX One sign-in step needed. Shipped same-day as 14 operations, not just one: Messaging (Send Message — execution-verified live against Lark Suite’s international API; Reply to Message, Get Message), Chats (Create Group Chat, Get Chat Info, Add Chat Member), Contacts (Get User Info, Batch Get User by Email/Mobile), Calendar (List/Create Events), Approval (Create/Get Instance), and Bitable (List/Create Records — Bitable’s own request/response envelope is fixed and certifiable even though a tenant’s table columns aren’t). Everything past Send Message is schema-verified against Lark’s official public API reference rather than execution-verified, since a live write there (a real group chat, calendar event, approval instance, or Bitable row) has no clean rollback — check an operation’s own verificationNote.
  • Changed (every session, read this if you send messages quickly): A new message now supersedes a turn that is still in progress on the same session. Before, the two ran at the same time, and the earlier one’s reply could arrive last, answering a question the user had moved past. Now the earlier turn stops at its next node boundary (or next streamed sentence, for a direct non-workflow agent). Its reply is neither published nor saved to the transcript, and only the newer message is answered. No InterruptedEvent is sent for a supersede, and the superseded workflow run is recorded as INTERRUPTED. Three things to check on your side. (1) If you correlate replies by clientTurnId, a superseded message never gets one, so stop waiting for it once a later reply arrives. (2) A turn that has already sent its first reply (an early acknowledgement from a response node) is never superseded and runs to completion alongside the newer message. A node already running, such as an action call, always finishes too. (3) The residual to design around: a workflow that performs a side effect before saying anything can have that step skipped if the user double-texts first. Put a response acknowledgement before any action that must not be skipped. Explicit interruptSession barge-ins now also drop a reply still being generated. Resumed await_reply turns, runWorkflowTask and scheduled runs are unaffected. See When a user sends a second message before the first reply.
  • Added: Two new voiceTier values — CHIRP3_HD and STUDIO. CHIRP3_HD is Google’s current-generation voice, positioned for conversational use, at a modest cost increase over NEURAL2. STUDIO is available but not recommended for a live voice session — Google tunes it for long-form narration, not dialogue, and it costs substantially more. Set either directly on AgentDto/UpsertAvatarConfigInput, same as any other tier.
  • Fixed: POST /stt could return the wrong languageCode on an otherwise-correct transcript — a fully English transcript (e.g. "Hi testing one two three") sometimes came back tagged cmn-Hans-CN or yue-Hant-HK. The tag is now corrected when the winning language’s script doesn’t match its own transcript (a Han-script language with zero Han characters in the text it produced). This does not change the transcript itself, and does not affect a transcript that’s genuinely mixed-script. Separately, if you’re seeing garbled (not just mistagged) transcripts: send your best actual guess as the primary languageCode, not a placeholder — real testing found the primary carries much more weight in recognition than the fallback list does, and narrowing the fallback list does not help.
  • Added: POST /stt now falls back across languages instead of recognising in exactly one. Every call is sent with English (en-US), Mandarin (cmn-Hans-CN), Malay (ms-MY) and Cantonese (yue-Hant-HK) as fallbacks, minus your primary languageCode and capped at 3. The response’s languageCode is now the language actually detected, where it used to echo the request. Before, a Mandarin or Malay voice note sent with en-US didn’t error; it returned a short garbage transcript. On our synthetic test clips, Mandarin and Malay went from a garbage transcript to correct on every run, and Cantonese went from a garbage transcript to correct on about half of runs, with the rest coming back empty. A new optional alternativeLanguageCodes field overrides the list (max 3, since the speech provider silently ignores the whole list past that, so a 4th is rejected with a 400). Send [] to turn fallback off. Two things to handle: a transcript recognised through a fallback language can come back with no confidence, reported as confidence: 0, so apply any low-confidence cutoff only when confidence > 0. And a clip that switches language mid-sentence is still transcribed in one dominant language. Hokkien and Hakka are not supported. Real accented voice notes haven’t been measured yet, only synthetic voices, so check against your own traffic. The LLM Playground’s transcribeSpeech also returns the detected language now.
  • Added / Deprecated: sessionActivitySummaries — a Relay-paginated, newest-first session list with agentId/status/search filters and totalCount, built for the new Supervision console. sessions is now @deprecated in favor of it — it still works, but returns every session the tenant has ever had with no pagination, which is a real problem for a tenant with a long history. Migrate any dashboard-style session list to sessionActivitySummaries.
  • Fixed: An llm node’s modelOverride now takes effect. Until now the field was saved but never read, so every llm node silently ran on the agent’s or tenant’s model regardless of what it was set to. The model a node runs on now resolves node modelOverride → agent llmModelOverride → tenant llmModelOverride → the provider’s default, most specific first. Two things to know before relying on it. (1) It picks a model, never a provider: the provider still comes from the agent/tenant llmProviderOverride, so a node can’t switch a non-vision provider into a vision one by itself. This corrects the 2026-09-17 imageVars entry below, which said setting modelOverride was enough. (2) The override is checked against the resolved provider’s model catalog (the same list the dashboard’s model picker shows). A model id that provider doesn’t serve now fails the run with an error naming the problem, where it would previously have been answered quietly by a different model. If you set modelOverride on nodes earlier, check those ids are valid for your provider before your next run.
  • Added: Scheduled Workflows: run an agent’s published workflow on a cron schedule, with no inbound message. Seven new dashboard-JWT operations: createScheduledWorkflow, updateScheduledWorkflow, pauseScheduledWorkflow, resumeScheduledWorkflow, deleteScheduledWorkflow, scheduledWorkflow, scheduledWorkflows. Each fire is a headless run with the same starting context as runWorkflowTask. Cron is strict 5-field only, so seconds-level and @daily-style aliases are rejected at save time, and the cron is evaluated in an IANA timezone you choose (default UTC). Three cost controls apply to every schedule: a per-plan limit on the number of schedules (paused and auto-disabled ones still count), the monthly LLM spend cap checked before every fire, and an automatic stop after N consecutive failed fires (maxConsecutiveFailures, default 3). Known limitation: an auto-disabled schedule sends no notification yet, no email and no webhook. Poll scheduledWorkflows for status: AUTO_DISABLED if a schedule matters to you.
  • Fixed: POST /stt no longer drops the rest of a voice message after a pause. For audio under ~50 seconds, which covers most real voice notes, the endpoint kept only the first transcript segment the speech provider returned. The provider typically starts a new segment at a pause, so everything after the first pause was silently lost, with a normal 200 and a normal-looking confidence. All segments are now joined into one transcript, and confidence is averaged across them, matching how longer audio was already handled. This affected every /stt caller, including the Telegram channel connector and the <vai-avatar> voice widget. If you were working around “the agent only heard the first half of my message,” you can drop the workaround. See Sending Voice Messages to an Agent.
  • Added: Six vision-capable models now available via BEDROCK_MANTLE (in addition to the existing GEMINI_DIRECT option). Set llmProviderOverride: BEDROCK_MANTLE and llmModelOverride to one of: mistral.mistral-large-3-675b-instruct, nvidia.nemotron-nano-12b-v2, mistral.magistral-small-2509, mistral.ministral-3-14b-instruct, google.gemma-3-27b-it, or moonshotai.kimi-k2.5. See LLM API: Vision-capable models for accuracy, latency, and implementation notes. Caveat: all testing to date has been on clean, synthetic images (high-contrast printed text); real-world accuracy on photographed documents may vary. Validate against your own image sources before committing to a model.
  • Docs (capability was already live, just undocumented): updateChannelConnectorSettings — toggles voice replies for a Telegram channel connector, either mirroring the input modality (voice in → voice out, text in → text out) or forcing every reply to be voice. This mutation and the ChannelConnectorDto.voiceReplyEnabled/alwaysVoiceReply fields shipped without a docs page; added now.
  • Fixed (same day, second pass): POST /stt’s OGG_OPUS handling now auto-detects the real sample rate from the audio file itself, instead of assuming one fixed value. Voice messages from different source apps can be recorded at genuinely different real sample rates (confirmed: Telegram’s own voice notes are 48kHz) — assuming a single value, even a corrected one, could never be right for every source. If you were previously working around empty-transcript results for some voice-note sources by explicitly passing sampleRateHertz, you can now leave it unset — see Sending Voice Messages to an Agent.
  • Docs (clarification, same day follow-up): the /stt fix below is confirmed working up to ~5 minutes of audio. A much longer clip (10+ minutes) could still hit an upstream limit and fail with an explicit 500 — not silently. If your integration handles very long recordings (e.g. no hard cap on user-recorded voice messages), see the updated constraints on Sending Voice Messages to an Agent and reach out if you need support for longer audio.
  • Fixed: POST /stt no longer fails on voice messages longer than ~60 seconds. Previously this endpoint used synchronous transcription exclusively, which silently rejected anything over a hard ~60-second ceiling with no clean way to work around it (the underlying speech provider’s own limit for that call shape). It now automatically routes longer audio through an asynchronous long-running path instead — same request/response shape, no chunking, just proportionally longer latency for longer audio. See Sending Voice Messages to an Agent for the updated constraints.
  • Docs fix: generateText’s images field was documented as “only consumed by BEDROCK_MANTLE” — this was stale. GEMINI_DIRECT also has native inline-image support and always has. No API behavior changed; only the docs were wrong.
  • Breaking: generateText is now a Mutation, not a Query. Migration: change query { generateText(...) } to mutation { generateText(...) } in your client — the field name, arguments, and response shape are all unchanged, only the operation keyword. This is architecturally correct for a billable action (it now matches startTextGeneration’s existing shape), but it is a real breaking change for any client still selecting it as a query. No known caller has integrated against generateText yet (it shipped 2026-08-27, ~4 weeks before this change); if you have, this needs a one-line fix on your side.
  • Changed: generateText now accepts either a dashboard JWT or an X-Api-Key header (was X-Api-Key only) — a dashboard-authenticated caller can now use it directly, alongside the existing API-key partner path. startTextGeneration and textGenerationEvents are unaffected — still X-Api-Key only.
  • Added: GenerateTextResultDto gained a new field, creditsUsedApprox (Int!) — an approximate credit-equivalent of costUsd, for previewing what a call cost under the credits pricing model. A blended estimate, not an exact per-model breakdown.
  • Added: A second, independent rate-limit ceiling now applies to generateText — 60 requests/minute per tenant, on top of its existing per-client-IP limit (raised from 10/min to 30/min in the same change). Both must pass. This is the one operation on the whole Rate Limiting page keyed partly by tenant rather than client IP alone, so a partner backend proxying many end users through one server IP isn’t bottlenecked by the IP tier. See Rate Limiting: LLM API.
  • Docs (clarification): updateWorkflow — spelled out explicitly that nodes/edges are a full replacement at every level, not a field-level merge: omitting a node drops it, and omitting a field (e.g. position) on a node you DO re-send drops just that field. Found live building a partner integration — a re-send that only touched config on some nodes silently wiped position from all of them.
  • Added: The webhook node can now embed a fetched file as base64 into its own bodyTemplate, via a new fetchAsBase64 field — up to 4 entries, each { urlTemplate, bodyField }, fetching either an http(s):// URL or an s3://bucket/key URI and injecting the base64 string at a dot path in the rendered JSON body. Until now this capability only existed on the action node (fetchAsBase64Var) — a webhook node calling a partner endpoint with an embedded file had no way to do so without a separate registered Custom Action. The two fields aren’t identical: webhook’s never touches ctx (it’s injected straight into the request body), while action’s writes the base64 string to a context variable. Also new: action node’s existing fetchAsBase64Var now accepts an s3://bucket/key URI too, not just HTTP(S) — useful when the file already lives in your own S3 bucket rather than being reachable over the public internet. An s3:// fetch uses the platform’s own AWS credentials and is fail-closed: your tenant’s bucket must be explicitly granted first (the platform’s own bucket is always denied, regardless of any grant). Both fetch paths share the same 10 MB cap. See Workflows Overview: fetching files for the full behavior.
  • Added (behavior change worth reading if your graph has a back-edge): A workflow node can now run more than once in a single turn, via a new maxVisits field on the node’s own config — available on every node type, defaulting to 1, capped at 100, validated on draft save and publish. Until now a run visited each node at most once: a back-edge was accepted at publish time and then silently dropped at runtime, with no error, no log line and no trace entry, and the run still reported COMPLETED. If you drew a retry loop and wondered why it never retried, that is why. Set "maxVisits": 3 on the node being retried and the back-edge genuinely re-runs it. Exhaustion is no longer silent, and there are three outcomes: (1) the node has an outgoing edge labeled 'loop_exhausted' — a new reserved label, valid on any node type — and the run follows it, which is how you give up gracefully instead of dying; (2) the node declared maxVisits and has no escape edge, so the run FAILEDs with a named error rather than quietly stopping; (3) the node never declared maxVisits, in which case that branch stops and the rest of the graph carries on exactly as before. All three now write a nodeTrace entry with status: "VISIT_LIMIT_REACHED", visitLimit and visitCount, where previously there was no record of any kind. Nothing changes for a graph that doesn’t opt in — an unset maxVisits reproduces the old behavior precisely, so an accidental back-edge someone drew months ago does not start failing runs today. Three things to check on your side: maxVisits has to be raised on every node inside the cycle, not just the one doing the work — a node left at the default halfway round the loop silently stops that branch before your escape edge is ever reached; nodeTrace may now hold more than one entry for the same nodeId, so anything keyed on “one entry per node” should key on array position instead; and every visit spends one of the run’s 250 node executions, so a loop meeting that ceiling reports BUDGET_EXCEEDED, not VISIT_LIMIT_REACHED. The dashboard’s workflow builder has no input for maxVisits yet — set it in the nodes JSON you pass to updateWorkflow (nodeConfigSchema already returns it for every type if you form-generate your own editor). See Loops and re-entry for a worked bounded-retry graph, and Loop re-entry and VISIT_LIMIT_REACHED for the API shapes.
  • Added: A new workflow node type, await_reply — ask the user a question, end the turn, and continue the same graph from that node’s successor when the next message arrives, with the workflow’s variables intact. Until now every inbound message restarted the graph at start, so “where were we?” had to be re-derived each turn (usually by paying an LLM node to read the history back and guess). Routing is by edge label: answered (required on every await_reply node) and unanswered (required, and only reachable, when matchMode is 'options'); both rules are checked at save/publish time, not mid-conversation. free_text mode takes any reply as the answer; options mode matches it against a list built from a context array and fixed at the moment the question is asked — by slot number, exact id, normalised label, then unique whole-token containment, every tier requiring exactly one winner, so an ambiguous reply cleanly takes unanswered instead of guessing. New surface, all additive: pendingAwaitReplies(sessionId: Int) (reference) lists what is paused and until when; WorkflowRunStatus.AWAITING_INPUT is the paused run’s status (not COMPLETED — exclude it from any “non-completed run = failure” alerting you have); and WorkflowRunDto.resumedFromRunId links the continuation run back to the paused one. Three things to know before adopting it: (1) the question is published with turnComplete: true, because the turn genuinely is over until a human replies — a client using a quiet-period/silence timer instead of turnComplete to decide “the agent is done” should switch before pointing it at a graph that uses this node; (2) a wait expires after 24 hours by default (configurable per node, hard ceiling 7 days) and is then simply dropped — it is never auto-resumed down unanswered, so build your own timeout handling if your flow needs a faster guarantee; (3) the dashboard’s workflow builder has no editor UI for this node yet — author it via updateWorkflow’s nodes JSON (nodeConfigSchema(type: AWAIT_REPLY) already returns a real JSON Schema if you form-generate your own editor).
  • Added (limits you should know about): A workflow run now executes under an explicit budget, and a graph is bounded when it is saved. Runtime: 250 node executions and 10 minutes of wall-clock time per run — exceeding either stops the run, persists it as FAILED, and writes one extra nodeTrace entry with status: "BUDGET_EXCEEDED" plus budgetLimit ("maxNodeExecutions" or "deadline"), budgetNodeExecutions and budgetElapsedMs, attributed to the node the engine refused to dispatch (that node never ran). Save time: 200 nodes / 400 edges max, rejected by updateWorkflow (drafts included) and publishWorkflow with Workflow has N nodes, exceeding the maximum of 200. Both ceilings are far above real usage — the largest graph on the platform is 73 nodes / 76 edges and the longest run ever recorded took 79 seconds — so this should be invisible unless something has genuinely run away. Branch on entry.status === "BUDGET_EXCEEDED" rather than parsing the error text; the three budget* fields appear on that entry only, so existing trace parsing is unaffected. Note the budget is checked between node dispatches, never mid-node, so a single hanging call is still bounded by that node’s own timeoutMs, not by this. See Workflow Overview: Graph size and run limits and Workflows API: Run limits and BUDGET_EXCEEDED.
  • Added: runWorkflowTask — a credentialed, headless way to run an agent’s published workflow as a task, with no chat message, no conversational session to drive and no reply to collect. Auth is X-Api-Key only (no JWT), and the tenant is resolved from the key itself, so the input has no tenant field. Facts travel as a flat variables map plus optional attachmentUrl/attachmentFilename/attachmentMime, seeded into exactly the same run context a chat attachment produces — so one graph can serve both a conversational caller and a task caller. The caller cannot name a workflowId: the agent owns which graph runs, as it does for a chat turn. Intended for an orchestrator’s webhook step, a channel gateway, your own backend, or a cURL. It is asynchronous — a successful return means the run was accepted and enqueued, never that it succeeded; read the real outcome from workflowRuns(sessionId) { nodeTrace context } (note that query is dashboard-JWT only, so an API-key-only integration should have the workflow report its own result outward via a terminal webhook node). Rate-limited at 60/min per client IP — see Rate Limiting: Workflow tasks.
  • Changed: A condition node whose expression references a variable the current run’s context does not hold now evaluates false and takes the false edge. It previously threw, which failed the entire run and produced a generic “Sorry, I ran into a problem” reply for what is usually just a turn that didn’t carry that fact. The unresolved names are recorded on the run’s context as _conditionUnresolvedVariables, so the reason is visible from workflowRuns(sessionId) { context }. Deliberately narrow: only an unresolved-variable reference maps to false — a syntax or type error still throws. If you were relying on a missing variable hard-failing a run, add an explicit gate instead. Practical upshot: someId > 0 is now a complete guard covering absent, 0, "0", "" and prose in one expression — use it (never someId != 0, which does not coerce) in front of any node that writes outward.
  • Added: A second, per-tenant rate limit now layers on top of the existing per-client-IP limit on every avatar-service route — POST /tts, POST /tts/google, POST /stt, and the newly-documented POST /avatar-session (see below). Both limits must pass — a caller can’t exceed either one. Per-tenant ceilings are set to 20x the existing per-IP figure on each route (600/min for TTS routes, 200/min for STT and avatar-session). An unauthenticated/invalid-token request is governed by the per-IP limit alone; the tenant limit only applies once a valid avatarToken resolves a tenant. See Rate Limiting: Avatar service and the REST reference.
  • Docs (previously undocumented): POST /avatar-session — negotiates a HOSTED_API (photorealistic video avatar) rendering session. This endpoint already existed but had never been written up on this site.
  • Docs (correction): Avatar Service REST API: POST /tts/google said its rate limit was “shared with /tts.” It isn’t — /tts and /tts/google each have their own independent counter (@nestjs/throttler keys per route handler); they just happen to use the same numeric limit. Corrected on that page.
  • Added: Knowledge base document upload (requestKnowledgeDocumentUpload) now accepts Markdown (text/markdown), CSV (text/csv), and XLSX (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet), alongside the existing PDF and DOCX — same 20MB cap, same three-step upload flow. Markdown is passed through as-is (no stripping); CSV and XLSX are re-serialized row-by-row as Header: value | Header: value | ... lines (XLSX flattens every worksheet, each demarcated by a === Sheet: <name> === line) rather than dumped as flat comma-joined text, specifically so a chunk boundary landing mid-file doesn’t strand a value with no way to tell which column it came from. See Knowledge Base (RAG): File upload.
  • Added: New action node config field, extractTextVar — an alternative output mode for fetchAsBase64Var’s fetch: instead of base64-encoding the fetched bytes into ctx[outputVar], it extracts plain text from them (through the same extractor registry the knowledge-base upload pipeline above uses — PDF, DOCX, Markdown, CSV, or XLSX) and writes the result into ctx[extractTextVar] instead, with ctx[outputVar] left unpopulated. Capped at extractTextMaxChars (default 50,000 characters, hard ceiling 200,000); ctx["${extractTextVar}Truncated"] is always written as a boolean so your graph can branch on a partial extraction. Requires fetchAsBase64Var to also be set. For mid-conversation document reading (a partner API hands back a file URL you want an llm node to read), as distinct from Knowledge Base ingestion, which is for content you want retrievable across future sessions.
  • Added: New llm node config field, imageVars — attach up to 4 images (named context variables holding a data URL or bare base64 string, ~5MB cap each) to a model call, alongside promptTemplate’s text. Read this before using it: the platform’s default LLM provider does not support image input, and an imageVars-bearing turn on an incapable provider fails the entire workflow run, not just the node — llm nodes have no success/failure edge routing to fall back on the way tool/action nodes do. Set modelOverride, or an agent-level provider override, to a vision-capable provider before using this field.
  • Docs: <vai-avatar>: Installation now documents a real gotcha for any integration that manages the SDK’s <script> tag itself (an SPA re-mounting the embed on route change, or a custom loader): removing and re-injecting vai-avatar.js in the same page session throws SyntaxError: Identifier '...' has already been declared, because its top-level const declarations persist in the browser’s global scope even after the <script> element is removed from the DOM. Fixed in Wetel’s own dashboard; if you wrote your own embed-loading logic, keep the script tag in the DOM permanently and only add/remove the <vai-avatar> element itself.
  • Docs (new page): Image Understanding for an Agent ties together the marker/fetchAsBase64Var/imageVars pieces documented individually above into one end-to-end walkthrough — how an attached image reaches a workflow, the graph shape that gets it into a model call, and the vision-capable-provider requirement (llmProviderOverride) it depends on. Includes a concrete right-vs-wrong example of the attachment marker’s leading-block-only parsing rule (a marker followed by text on the same line silently fails to parse — this bit a real integration the same day this shipped).
  • Added: POST /stt now accepts OGG_OPUS as an audioEncoding value, alongside the existing MP3/LINEAR16/WEBM_OPUS. This matters because OGG_OPUS is the native format WhatsApp and Telegram voice notes are already recorded in — if your integration forwards a voice note to /stt, you no longer need to transcode it first. Defaults to a 16kHz sample rate when sampleRateHertz is omitted (matching WhatsApp’s native rate), distinct from WEBM_OPUS’s 48kHz default. New walkthrough: Sending Voice Messages to an Agent — a copy-paste guide for transcribing a voice note with /stt and feeding it into an agent via sdkSendMessage, using the avatarToken from sdkStart (nothing restricts that token to the <vai-avatar> widget — it works the same way called directly from your own backend).
  • Docs (correction): Avatar Service REST API: POST /stt overstated the request size limit as “~5MB of raw audio (~7,000,000 base64 characters)”. The real, current limit is ~4MB of raw audio (~5,600,000 base64 characters) — corrected on that page.
  • Added: refreshAvatarToken — renew a session’s avatarToken without starting a new session. An avatarToken lives about an hour, and until now there was no way to renew one: a conversation that outlived its token simply started failing with Invalid or expired avatar token, with no recovery path short of a fresh sdkStart (and a fresh session). This matters for any long-running session — an embed left open on a tab, a channel-bridged chat a user returns to later, a headless integration holding one session for hours. Pass the current token as input.avatarToken (not as an Authorization: Bearer header — this is the one avatarToken operation that has to accept an already-expired token, which the normal header guard rejects outright); you get back a fresh full-TTL token, the same sessionId, and expiresInMs so you can schedule the next refresh off the server’s TTL instead of hardcoding an hour. You don’t need to refresh pre-emptively: a token that expired within the last 10 minutes is still accepted (AVATAR_TOKEN_REFRESH_GRACE_MS, configurable per deployment), so the reactive “call → get the auth error → refresh → retry” pattern works. Only the expiry bound is relaxed — the signature is verified in full, the session must still be ACTIVE, and the replacement carries the same session, tenant and avatar config, so it never widens a token’s authority. Rate-limited to 10/min per client IP (see Rate Limiting). The one thing to get right: a refreshed token cannot be swapped into a live sessionEvents WebSocket — graphql-ws reads connectionParams only once, at connection open — so close and reopen the subscription with the new token. That failure is silent, not loud: HTTP mutations resume on the new token while an un-reconnected socket holds an expired one and quietly stops delivering events. sdkSendMessage/sdkEndSession need no special handling; each sends its own header and picks the new token up on the next call. Full walkthrough in Authentication → Refreshing the session token.
  • Docs (correction): Authentication said an avatarToken was “short-lived (about 24 hours)”. It has been about an hour (AVATAR_TOKEN_TTL_MS, 3,600,000 ms, on every deployed environment). If you sized a session-keepalive or refresh strategy off the 24-hour figure, it was wrong by a factor of 24 — this is exactly the gap refreshAvatarToken above now closes.
  • Added: credentialSecretHeaderName on createCustomAction/updateCustomAction. A Custom Action’s stored bearerToken used to only ever render as Authorization: Bearer <token> — this lets you send it under any static header name instead (e.g. X-Api-Key, X-Client-Secret), with no Bearer prefix and no Authorization header sent, for third-party APIs that authenticate that way. Only applies to the plain self-service bearerToken credential; rejected on create unless bearerToken is supplied in the same call, and rejected on update if the action has no stored secret or uses a Wetel-provisioned/session-identity credential source. See Action Node: Credential header name for how it interacts with extraHeaders.
  • Changed: extractFirstInteger now stores a real number in outputVar, not a digit string — it used to be a string. Motivation: a condition node’s expression is evaluated with no type coercion, so a "0" string sentinel compared with myVar != 0 evaluated true (a string is never equal to a number), silently routing the “no match” case down the found branch. A live run hit exactly this and fired two outbound writes with an id of 0 before the partner API rejected them. If your workflow gates on this sentinel with myVar != "0" or {{#if myVar}}, update it to a plain myVar != 0 / myVar > 0 — that’s now what the value actually is. Rendering in a template is unaffected: {{myVar}} and "{{myVar}}" still render the same way they did before.
  • Changed: the findId helper’s idField argument can now be a dotted path (e.g. "current_application.id") to reach a nested field. A missing or non-object intermediate segment resolves to the same 0 “no match” sentinel rather than throwing, so a partner-side shape change degrades onto the existing not-found branch instead of failing the run. A numeric-string id (some partner APIs, including Nortia, return ids as "21" rather than 21) is now coerced to a real number too — findId always returns a number or 0. Motivation: a nested current_application.id lookup that a flat-field-only findId couldn’t reach at all.
  • Changed: the findId Handlebars helper now matches in three tiers — exact, then normalised (case, whitespace, punctuation, diacritics, trailing plurals), then unique whole-token containment — and returns 0 whenever a tier is ambiguous instead of picking the first hit. Motivation: a live run where an llm node wrote Solutions Architect for a posting titled Solution Architect; the exact-only lookup rendered an empty id and the partner API rejected the write with a 422 after the agent had already told the candidate it was filing their application. Behaviour change to know: duplicate values in the array now return 0 at every tier (previously first-wins). Details and the “route the 0 case before any write” rule in Workflow Best Practices.
  • Added: Channel attachment/marker parsing moved server-side. Every workflow run’s context now carries attachment ({ url, mime, filename } or null), channel ({ trigger, nortiaEnabled, nortiaCompanyId, raw }), and flat attachmentUrl/attachmentMime/attachmentFilename mirrors (empty string when absent) — seeded from the leading block of [[...]] marker lines a channel prepends to an incoming message (the Telegram gateway’s [[WETEL_ATTACHMENT url=... mime=... filename=...]], and OmniChat’s one-per-line [[trigger:...]]/[[nortia:enabled|company|resumeUrl|resumeName=...]]). If your graph used to run its own llm node to pull url/mime back out of the marker text, you can delete it — use the flat mirrors directly in a condition expression (they’re safe to compare even with no attachment present) and as fetchAsBase64Var’s source (it takes a bare variable name, not a dot path). Markers are not stripped from userMessage, so a graph that already extracts them itself keeps working unchanged. See Channels: attachment markers and the updated file-intake cookbook. If you’re bridging your own channel (not a packaged Channel Connector) into sdkSendMessage, neutralize [[ in any end-user-typed text before prepending your own marker — otherwise a user-typed line can be parsed as if the platform itself sent it.
  • Resolved (follow-up to 2026-09-09’s open question below): Nortia’s REST Integration API (List Job Postings (REST), Submit Resume (REST)) now authenticates with a static, service-owned secret header — Nortia issued Wetel one, and the old bearer-token path these two operations previously relied on has been removed on Nortia’s side entirely. Both operations are confirmed working end-to-end against the real API. Behavior change if you had a Custom Action pointed at either operation with credentialSource: "SESSION_NORTIA": that combination is no longer offered — a workspace-wide service secret carries no signed-in end user’s identity, so a per-user credential source no longer makes sense for these two operations specifically. Use credentialSource: "NORTIA" (Wetel-provisioned shared credential) instead; SESSION_NORTIA is unaffected on every Nortia GraphQL operation, where it continues to carry the real signed-in user’s own identity as before.
  • Docs (correction): Rate Limiting was stale — it still listed sdkStart as unthrottled (it has been 30/min since 2026-09-08) and omitted generateWorkflowDiff (20/min), ingestDocumentFromUrl, generateText/startTextGeneration, and the auth-mutation limits added 2026-09-08. The page now lists every enforced limit, and states explicitly that all GraphQL limits are keyed by client IP, not by API key or tenant — two other pages that said “per key”/“per tenant” were corrected to match.
  • Docs (previously undocumented): nodeConfigSchema / nodeConfigSchemas — shipped 2026-08-31, callable with a JWT or an API key, returns a real JSON Schema per workflow node type so an OEM builder can form-generate its property panels from the live schema instead of a hand-copied snapshot. Build Your Own Admin Panel now points at it, and its per-node config table was corrected (llm should expose promptTemplate, not systemPrompt, as the main field; action is a Custom Action + certified-operation picker, not a raw URL form; end_session has no final-message field).
  • Docs: sub_agent now documents two requirements previously only discoverable at runtime — every target agent must have a published workflow (otherwise that target returns status: 'error'), and the dispatch depth cap is exactly 5.
  • Docs: Workflow Best Practices documents the third Handlebars helper, joinList, and states plainly that there is no eq/comparison helper — equality branching belongs in a router/condition node.
  • Fixed (read this if a workflow using a Nortia certified operation is reading current_status, reply, or dept_head_name from a response): Nortia’s live API changed a handful of response field names since these operations were first certified — 10 of Wetel’s certified Nortia operations broke as a result (they would have failed outright at runtime). All 10 are fixed as of today: Application.current_status is now status (affects Update Application Status, Move Application Stage, Record Not Hired, Record Manager Decision, Record Interview Outcome, Propose Expert Interview, and Assign Candidate to Job); AgentAnswer.reply (Ask Matchie) is now answer; Department.dept_head_name (Update Department) is now a relation, dept_head { id name }, rather than a plain string field; and JobPosting.interview_strategy (Get Job Posting) was removed with no confirmed successor yet, so that field is temporarily dropped from the certified selection rather than guessed at. Every affected operation’s certifiedOperationId is unchanged — the fix was applied to the operation’s own definition, so no workflow needs re-wiring. If your workflow templates read one of the old field names from a Nortia action’s output, update the template to the new name.
  • Added: 4 new Nortia certified operations — Get Candidate (candidate(id), read), Get Interview Info (interview(token), read — requires the interview invitation token, not a general “my interviews” list), AI Usage (aiUsage, read), and Update Job Posting (updateJobPosting, write — takes a full replacement payload, not a sparse patch). Catalog total is now 176 (Nortia 79 — 77 GraphQL + 2 REST, OmniChat 59, aisCRM 38).
  • Documented (no behavior change yet — open question): Nortia’s REST Integration API documentation now describes a different authentication scheme (a static, service-owned secret header) than the bearer-token flow Wetel’s REST certified operations (List Job Postings (REST), Submit Resume (REST)) currently use. Whether the existing bearer-token flow still works has not yet been confirmed either way — nothing has changed on Wetel’s side pending that confirmation. If this turns out to be a breaking change, it will get its own callout here.
  • Fixed (security — read this if any ACTION/WEBHOOK/TOOL/MCP target or externalSyncUrl ever redirects, or its hostname doesn’t resolve): Outbound calls made on your behalf — action nodes, webhook nodes, tool/MCP connector calls, ingestDocumentFromUrl, and externalSyncUrl — no longer follow HTTP redirects blindly. A redirect target is now either rejected outright (action/webhook, which set maxRedirects: 0, since no legitimate target needs to redirect) or re-validated against the same private-address check before being followed (the fetch-based paths: knowledge-document ingestion, MCP connector calls, externalSyncUrl). Separately, a target hostname that doesn’t resolve at all is now rejected, both at save time (updateAgent, workflow publish) and at call time — previously an unresolvable hostname was treated as allowed rather than blocked. If your action/webhook URL legitimately 301/302/307/308s (e.g. an http://→https:// upgrade, a trailing-slash normalization), point the node directly at the final URL — see Troubleshooting for the exact symptom. If a URL’s hostname doesn’t resolve from Wetel’s own servers, it can no longer be saved at all (e.g. an internal-DNS-only name, or a partner endpoint mid-provisioning) — the error names the hostname so it’s diagnosable.
  • Changed: On a Custom Action whose credentialSource is a Wetel-provisioned, tenant-wide shared credential ("OMNICHAT" / "NORTIA" / "AISCRM" / "LARK_TENANT"), url and method are now immutable via updateCustomAction — a change to either is rejected with an error telling you to create a new action instead. This closes a gap where such an action could be repointed at an arbitrary endpoint while still sending Wetel’s own shared partner credential on every call. Session-identity credential sources (SESSION_OMNICHAT/SESSION_AISCRM/SESSION_NORTIA — your own end user’s identity, not a Wetel-shared one) are unaffected; you can still retarget those the same as before.
  • Changed (no client change needed for any existing integration): The avatarToken returned by sdkStart is now bound to the specific session it was issued for. sdkSendMessage, sdkEndSession, sdkVerifyWebbyxOneIdentity, requestStudyKitExport, interruptSession, and the sessionEvents subscription now reject any call whose sessionId doesn’t match the sessionId the presented avatarToken was actually minted for, with Forbidden. Every integration that already uses the {sessionId, avatarToken} pair together exactly as returned from a single sdkStart call — which is every known caller in this repo’s own reference tools and SDKs — is unaffected. Tokens minted shortly before this change keep authenticating during a short rollout window via a legacy compatibility path; that path is temporary and will be removed in a follow-up.
  • Added: Rate limits now apply to login, register, refreshToken, loginWithWebbyxOne, isRegisterable, generateOTP, validateSecuredToken, forgotPasswordWithOTP, resetUserPasswordWithUrl, and sdkStart. A caller hammering one of these repeatedly now gets a rate-limit error instead of an unlimited retry budget — keyed per source IP, so a shared IP in front of many end users (a corporate NAT, a proxy) shares one budget across all of them.
  • Breaking: generateOTP no longer accepts the optional top-level audience argument — it has been removed from the schema entirely. If your integration ever passed audience alongside input (even the default "customer" value), drop it; the call now only takes input. No known caller passed a non-default value; if yours did and it appeared to work, that was actually a gap — a non-"customer" value let an unauthenticated caller mint an OTP for a contact that had never registered.
  • Changed: OTP codes generated by generateOTP are now purpose-bound — a code minted for one TokenPurpose (e.g. SIGN_UP) can no longer be spent against a different flow expecting a different purpose (e.g. forgotPasswordWithOTP’s RESET_PASSWORD). Each code also now locks out after 5 wrong attempts, returning the same generic error used for an unknown or expired code — so a caller can’t distinguish “wrong code,” “locked out,” and “no such code” from the response alone. If your integration reuses one OTP across more than one purpose, or retries a wrong code more than a few times, both now fail; request a fresh OTP for the specific purpose you need.
  • Added: response node structuredContentTemplate and the corresponding MessageDto.structuredContent field. A response node can now attach an optional structured payload (card | table | list) alongside its plain-text reply. Rendering fails safe — a bad template never fails the node, structuredContent is just null and the text reply still sends. Dashboard-only consumer today; no interactive/button variant yet.
  • Docs: New recipe, Channel Agent with File Intake and a Partner Write-Back — a generalized, replicable build guide for a headless channel-connected agent that collects information over a conversation, accepts a file, and writes the result to a partner API via a certified operation. Generalized from a real, Kai-verified-end-to-end Telegram + Nortia HR screening build (role selection, a live PDF résumé submission, and a real candidate record created on Nortia). Includes a copy-pasteable workflow graph and a gotchas checklist covering the reliability patterns this build actually hit.
  • Documented (not a behavior change): Channels now documents the [[WETEL_ATTACHMENT url="..." mime="..." filename="..."]] marker a channel connector prepends to an incoming message’s text when the user sends a file — this convention already existed (shipped alongside Channel Connectors’ Telegram document/photo intake) but was never written up publicly until now. Also newly documented: Telegram’s /start session reset — ending the mapped session and replying with the agent’s own openingGreeting on /start, giving an end user a self-serve way out of a long or stuck conversation with no operator intervention needed. Both behaviors are already live; this closes a real documentation gap, not a new capability.
  • Docs: Workflow Best Practices now documents the findId Handlebars helper in full (signature, worked example, the specific “an LLM confuses a code/ISBN’s own digits for the real id” failure it exists to avoid). Action Node and Tool Node already linked here for this, but the section itself didn’t exist yet — those links previously landed on a page with no matching content.
  • Added: runRegressionScenario and workflowRegressionResults — Phase B of workflow regression scenarios. A saved scenario (captureRegressionScenario, shipped 2026-09-01) can now actually be replayed against your workflow’s current draft, and gets a real pass/fail verdict. Pass/fail is structural only — the same node-visitation path, router/condition branch, tool calls, and terminal node as the baseline — never the literal wording of an LLM/voice response, since that’s non-deterministic by design. runRegressionScenario returns immediately (before the replay finishes, since it drives a real LLM turn); poll workflowRegressionScenarios/workflowRegressionResults a few seconds later for the outcome.
  • Added: agentAuditLogs — this agent’s change history (who changed what, when). v1 covers updateAgent only — a field set to the value it already had is not recorded, and there’s no tenant-wide admin log across every agent yet, just this per-agent history.
  • Added: setPrimaryBillingAccount — sets which system bills your tenant’s usage: WEBBYX_ONE (WebbyX One’s own credit ledger) or WETEL (Wetel’s own billing; re-enables the tenant’s normal per-plan monthly LLM spend cap). Explicit and user-set — never inferred from which login method a given session happened to use. Selecting WEBBYX_ONE requires the account already have a linked WebbyX One identity via linkIdentity — throws otherwise. myTenant now exposes the current value as primaryBillingAccount.
  • Added: the certified operation catalog is no longer exclusively GraphQL — 2 new Nortia entries, List Job Postings (REST) and Submit Resume (REST), run against a separate plain REST API (Nortia’s separate “Integration API” host, distinct from its GraphQL admin API). New operationType values "rest_get"/"rest_post" alongside the existing "query"/"mutation". For a REST certified operation, argsTemplate is the full request body/params directly (no {query, variables} wrapper) — see Action Node: Certified operations. Both new entries are schema-verified only — read directly from Nortia’s own live, published OpenAPI spec — not yet live-execution-verified; see the operations’ own verificationNote. Catalog total is now 172 (Nortia 75, OmniChat 59, aisCRM 38).
  • Added: a new action node config field, fetchAsBase64Var — an alternative to the normal customActionId-driven call. Set it to a context variable holding a URL, and the node fetches that URL’s bytes server-side, base64-encodes them, and writes the result to ctx[outputVar]. Built specifically to feed a certified operation’s file_base64-shaped request field (e.g. the new Submit Resume (REST) above) without needing a real GraphQL-multipart file upload, which no executor in this platform supports today. customActionId becomes optional on action nodes when fetchAsBase64Var is set — every other field on the node is ignored in this mode. Same SSRF protection as every other action node call; sends no Authorization header, so only point it at a URL meant to be fetched without auth.
  • Added: Request limits — every GraphQL query/mutation now enforces a max query depth of 7 and max query complexity of 50, rejected as a pre-execution validation error. Applies in all environments, doesn’t affect introspection. The schema’s own real nesting is shallow (one Relay connection, no deep object graphs), so this shouldn’t affect any normal integration query — it’s a worst-case resource-cost bound, not a new constraint on legitimate usage.
  • Fixed: on a tool/action/webhook node’s HTTP failure, nodeTrace’s error field now includes the partner endpoint’s own response body — its status code and JSON message/error/errors field (or up to ~500 chars of raw text for a non-JSON body) — instead of only axios’s generic "Request failed with status code 400". Previously the actual reason a partner rejected a request (a validation message, a missing-field error) was invisible from Wetel’s side entirely; this was the exact blind spot behind an unresolved aisCRM write-400 in our own integration testing. Bounded and redacted the same way as renderedRequest. Not retroactive.
  • Changed: sdkSendMessage’s and sendMessage’s text field limit raised to 10,000 characters (previously 4,000 for sdkSendMessage, 5,000 for sendMessage — the two had drifted with no principled reason for the difference; both are aligned now). No other behavior changes — the field was already stored in an unbounded Postgres column, this is purely the request-validation ceiling. If your integration constructs a large per-turn payload (e.g. embedding conversation history or system instructions directly into text), consider whether Wetel’s own Agent personaPrompt and native multi-turn history would let you send a shorter message instead — cheaper on LLM input tokens per turn, and won’t hit this ceiling again as conversations grow.
  • Expanded: the certified operation catalog’s Nortia coverage grew from 64 to 73 operations, adding 9 hiring-pipeline write mutations: application status/stage updates, application notes, not-hired/manager-decision/interview-outcome recording, expert-interview proposal, candidate assignment, and candidate contact-detail updates. All SESSION_NORTIA-compatible. Unlike most of the catalog, these 9 are schema-verified only, not live-execution-verified — Nortia has no disposable candidate/application fixture to safely round-trip a write against, so exercising them for real would mutate an actual hiring record. Check an operation’s verificationNote before relying on one for anything beyond a controlled test. Catalog total is now 170 (Nortia 73, OmniChat 59, aisCRM 38).
  • Fixed: createCustomAction/updateCustomAction now accept an input.credentialSource field, restricted to "SESSION_OMNICHAT"/"SESSION_AISCRM". Previously these two values were never actually reachable through either mutation — despite Credential sources already describing SESSION_OMNICHAT/SESSION_AISCRM as “any tenant, self-serve,” there was no way to set a new custom action onto that flow without direct database/seeder access, so the practical self-serve path for calling OmniChat/aisCRM described on this page and on the certified operation catalog page didn’t fully work end to end until now. Mutually exclusive with bearerToken/larkAppId+larkAppSecret; switching an existing action onto a SESSION_* source clears any stale stored credential. OMNICHAT/NORTIA/AISCRM (tenant-wide shared account) and LARK_TENANT remain not directly settable here — see the credential sources table for why.
  • Added: createCustomAction/updateCustomAction’s input.credentialSource now also accepts "SESSION_NORTIA", alongside the existing "SESSION_OMNICHAT"/"SESSION_AISCRM". Nortia now has the same self-serve, session-identity-driven credential flow as OmniChat and aisCRM — sign in via WebbyX One, link your Nortia identity (sdkVerifyWebbyxOneIdentity’s productTickets), then reference the action’s id. No Wetel-provisioned shared account needed. See Credential sources and the certified operation catalog for the full picture.
  • Added: renderedRequest (see below) now runs through a best-effort secret-redaction pass — JWTs, Bearer tokens, vendor key prefixes (sk-, pk-, Slack, AWS), and long values under sensitive-sounding key names are redacted before the trace is stored. Defense-in-depth, not a guarantee — see Observability & Debugging Your Agent for what it does and doesn’t catch. Never hardcode a secret into argsTemplate/bodyTemplate — use a Custom Action’s credential-source mechanism instead.
  • Added: workflowRuns/workflowRun’s nodeTrace entries now include renderedRequest for tool, action, and webhook nodes — the actual interpolated request body/args sent to the partner endpoint, captured on both success and failure. Previously only the response (outputValue/error) was visible in the trace, making it hard to tell whether a bad result came from a malformed argsTemplate/bodyTemplate on Wetel’s side or an issue on the partner’s own endpoint. Usually an object, but comes through as a raw string if the template failed to render to valid JSON; absent on trace data captured before this shipped. See Observability & Debugging Your Agent.
  • Added: Login with WebbyX One — three new mutations let a person log into the Wetel dashboard using their WebbyX One (IAM One) account: loginWithWebbyxOne(ticket) (public, logs in or silently creates a Wetel account + auto-provisions/joins a tenant keyed to the WebbyX One company), and linkIdentity/unlinkIdentity (JWT-guarded, add/remove a WebbyX One link from an already-logged-in account’s settings). Purely additive — never replaces or disables password login for any account; if a WebbyX One identity’s email matches an existing Wetel account, it auto-links to that account and its existing tenant always wins over a company-based one. This is a distinct mechanism from sdkVerifyWebbyxOneIdentity (session-scoped end-user identity linking for calling certified operations) — see the note on both pages if you’re unsure which one you need.
  • Added: Opt-in success/failure edges for tool and action nodes — draw one outgoing edge labeled success and one labeled failure (same mechanism condition nodes already use for true/false) and the runner routes to failure on any thrown error instead of failing the whole run, writing the error message to a _lastNodeError context variable. Fully additive and backward compatible — a node with neither label keeps today’s exact behavior (every edge fires unconditionally, an unhandled error still fails the run). A failure edge requires a matching success edge; publishing without one is rejected. Available in the Workflow Editor UI via a card-toolbar toggle that swaps the single generic handle for the labeled pair.
  • Added (Phase A — capture and list only; replay shipped 2026-09-06, see above): Workflow regression scenarios — captureRegressionScenario freezes an already-completed WorkflowRun as a named baseline (reconstructed input script, nodeTrace snapshot, and derived structural assertions), and workflowRegressionScenarios lists them per workflow. This exists to catch a real, documented failure mode — an intent/classification LLM node silently misrouting after a model change, with no error anywhere.
  • Expanded: the certified operation catalog (launched 2026-08-29, below, with 3 starter operations) grew to 161 certified operations — Nortia (64), OmniChat (59), aisCRM (38) — covering near-complete read and write access to each partner’s real API, not just a handful of example queries. New coverage includes full CRUD on OmniChat contacts/conversations/tasks/teams/opportunities plus automation/macro/SLA/canned-response management; Nortia candidate and job-posting lifecycle operations, talent pools, company settings, and usage/billing reads; and aisCRM companies/contacts/deals (including pipeline and win/loss moves), tasks, notes, and pipeline configuration. Every entry was verified live against each partner’s real, current API before being added — check certifiedOperations(product: ...) for the authoritative, current list.
  • Fixed: the certified operation catalog (added 2026-08-29, below) returned an empty list / a 404 on certifiedOperation(id) against both staging and production for a short window after launch — the table existed but hadn’t been seeded yet. Fixed same day; certifiedOperations/certifiedOperation are confirmed live and returning real data on both aws-staging and aws-production now. If you hit this earlier, it’s resolved — try again with the id your own certifiedOperations query returns (ids are per-environment; don’t hardcode one from a doc example).
  • Added: the certified operation catalog — for OmniChat, Nortia, and aisCRM action nodes, pick a Wetel-verified GraphQL query or mutation from certifiedOperations/certifiedOperation instead of hand-authoring the request body yourself. Set ActionNodeConfig.certifiedOperationId and argsTemplate becomes that operation’s GraphQL variables. Also available from the Workflow Editor UI as a second dropdown on the action node’s config panel, once the chosen Custom Action’s credential source has a matching certified catalog. Fully additive — existing hand-authored argsTemplate bodies keep working unchanged. See Custom Actions API: Credential sources for which credential sources are self-serve today (SESSION_OMNICHAT/SESSION_AISCRM via WebbyX One sign-in) versus Wetel-provisioned only (OMNICHAT/NORTIA/AISCRM).

  • Added: a new sub_agent workflow node type — dispatches to one or more other agents concurrently, each running as its own independent session (own turn history, own cost tracking), and aggregates whatever comes back into ctx[resultVar]. This is the building block for multi-specialist orchestration: a dispatcher agent classifying a message and handing off to a billing specialist and a technical specialist at the same time, then combining both replies. One target failing or timing out never blocks or fails the others — see Sub-Agent Node for the full config reference, including the important gotcha that resultVar is always an array, even for a single target, so {{resultVar}} on its own renders [object Object] — index a specific entry ({{resultVar.[0].text}}) instead.

  • Docs fix (read this if any llm workflow node sets both systemPrompt and promptTemplate): LLM Node’s systemPrompt field description was wrong, and this page’s own worked example was broken as a result — copying it produced a node that silently never receives its promptTemplate data. When systemPrompt is set, the rendered promptTemplate is used only for token-cost accounting and never reaches the model in any form, not “used as well” as the old description said. The model still answers — with zero real context — which for a data-lookup node means a confident, fabricated-sounding reply with no error anywhere. Fix: leave systemPrompt unset in almost every case — put your instructions and any {{ctxVar}} data injection together in promptTemplate alone. The page’s worked example is corrected to match.
  • Added: createCustomAction now supports Using Lark — send messages, create/query Base records, route approvals, and more, using your OWN Lark app (not a shared credential). Supply larkAppId/larkAppSecret/larkRegion instead of bearerToken (mutually exclusive with it); Wetel exchanges them for a Lark tenant_access_token and keeps it refreshed automatically. No new node type — this is a Custom Action/ACTION node integration like any other.
  • Added: New LLM API page — generateText (single-response) and startTextGeneration + textGenerationEvents (streaming) let you call an LLM directly through Wetel with no session, agent, or workflow, and no need to set up your own model provider credentials. API-key auth, same tier as embed. textGenerationEvents is the first subscription in this API to authenticate via X-Api-Key in connectionParams instead of a dashboard JWT — see the page’s own WebSocket auth note if you’re building a streaming client.
  • Added: Both LLM API operations now enforce a per-tenant monthly spend cap by plan tier. A call made once you’re at or over the cap fails immediately with a BadRequestException naming your limit and current spend — before any provider is called, so it never incurs cost.
  • Docs: LLM API gained a Troubleshooting section after live-testing the streaming path end to end — Redis pub/sub delivers textGenerationEvents with no replay buffer, so a client that subscribes even slightly after generation finishes silently receives nothing (no error either side). Documents the fix (connect, wait for the socket to actually open, then start generation) and the graphql-ws gotcha behind it: the client’s default lazy: true means connected never fires until something calls subscribe() first, which deadlocks a naive “wait for connected” pattern — pass lazy: false instead.
  • Fixed (behavior change — read this if you edit a published workflow via the API): Editing an already-published workflow via updateWorkflow no longer takes effect on a live session automatically. Previously, updateWorkflow wrote directly onto the same graph a live session executed, so an edit to an already-published workflow went live the instant the mutation returned — with no republish step, contrary to what this page’s own “two-call create pattern” section already documented for a first-time publish. That was an unintended gap, not documented behavior. Now updateWorkflow always writes to a separate draft; a live session only ever runs whatever was captured by the most recent publishWorkflow call. If your integration edits an already-published workflow and expects the change live without a follow-up publishWorkflow call, add that call now — see Workflows API: the two-call create pattern for the corrected, now-accurate-in-all-cases description. The workflow query’s own nodes/edges fields were also clarified to state plainly that they reflect the draft, not necessarily what’s live.
  • Added: sendMessage accepts a new testDraft: Boolean input field — when true, that turn runs the agent’s workflow’s live draft instead of its published snapshot, no publish required. For testing in-progress edits from a dedicated test session only; never set this on a real user’s turn.
  • Added: Five new knowledge-base operations — deleteKnowledgeDocument (soft-deletes a single document and its chunks, unlike deleteKnowledgeBase, and releases the tenant’s quota reservation), resyncKnowledgeDocument (retries a FAILED file’s parse, or re-chunks/re-embeds a READY document’s stored text in place), ingestDocumentFromUrl (ingests a single web page — no link-following, SSRF-protected), knowledgeDocument (fetches one document including its full rawText, kept separate from the knowledgeDocuments list query so listing a KB never pays for fetching every document’s text), and updateKnowledgeDocumentText (overwrites a document’s text and re-indexes it in place — the “edit” counterpart to resync). All fully additive; no existing operation’s behavior changed.
  • Added: New query apiUsageSummary — this month’s LLM spend, the tenant’s monthly spend cap, and a per-model/per-operation call-count breakdown. Backs the dashboard’s new Developers page. Tenant-wide only; today’s schema can’t break usage down by individual named API key.
  • Added: sdkVerifyWebbyxOneIdentity now accepts an optional productTickets array and returns a new linkedProducts result field — a session’s signed-in WebbyX One identity can now be linked to sibling WebbyX-group products (aisCRM, OmniChat today) as itself, instead of a workflow always authenticating against those products as a single shared, admin-configured service account. Fully additive: omit productTickets and nothing changes. Requires minting an extra ticket per product client-side, in the same sign-in submit as the main ticket — see the mutation’s docs for why this can’t be done later or server-side.
  • Added: New query webbyxOneProductClientIds — returns the public WebbyX One X-Client-Id for every sibling product a signed-in identity can link via the productTickets field above. Lets a sign-in widget discover which extra /sso/login calls to make without hardcoding a product list. Accepts either a dashboard JWT or the avatarToken from sdkStart.
  • Added: Custom Actions now support extraHeaders on createCustomAction/updateCustomAction, returned on CustomActionDto — a plain { [key: string]: string } map of additional static headers merged onto every call made by an ACTION workflow node, alongside (and never overriding) the existing Authorization header. Unblocks target APIs that require more than one static header, such as a partner requiring both Authorization and a separate x-organization-id identifier header. Not a secret-storage mechanism — it’s stored and returned as plain JSON, visible to anyone who can query customActions for your tenant; genuinely secret values still belong in bearerToken, the one credential a custom action supports. See Custom Actions API: Extra static headers and Action Node.
  • Docs: Workflow Best Practices now documents the _{outputVar}Structured / _{outputVar}StructuredJson context keys a tool node writes alongside its plain-text outputVar — populated from the MCP protocol’s structuredContent field, or auto-parsed as a fallback if your server returns JSON-shaped text instead. Also documents two supported response node design choices for consuming it: extracting just a human-readable field for a plain-text client, or hand-authoring a structured JSON envelope (with jsonString) for your own frontend to parse client-side. Neither behavior is new — this closes a real documentation gap found while helping a partner debug a raw-JSON-leaking reply, which turned out to be a template-wiring choice, not a bug in either the platform or their integration.
  • Fixed: sdkVerifyWebbyxOneIdentity’s docs incorrectly described the SSO login step as a redirect to a WebbyX-One-hosted login page, and stated the ticket TTL as ~60 seconds. Neither is correct: WebbyX One has no hosted login page for this flow — your own frontend hosts the email/password form and calls POST /sso/login directly with those credentials — and the real, confirmed ticket TTL is 5 minutes. Docs corrected to match the real, live-tested contract; the mutation’s own behavior was never affected, only its description.
  • Added: sdkVerifyWebbyxOneIdentity — exchanges a WebbyX One (IAM One) SSO ticket for the caller’s verified identity and stores it on the Session, so a workflow’s router/condition nodes can branch on {{session.webbyxOneIdentity.companyIds}} and similar fields. No <vai-avatar> embeddable SDK component ships a “Sign in with WebbyX One” affordance yet — Wetel’s own dashboard (Agent Testing screen) has a reference implementation of this exact flow if you want to see it working end to end before building your own.
  • Changed: Bedrock Mantle is now the platform default LLM provider (previously opt-in only via llmProviderOverride: BEDROCK_MANTLE, as noted below). If you never set llmProviderOverride on your agents or tenant, conversation turns are now served by Bedrock Mantle by default rather than the prior default provider — this can change latency, reply phrasing, and cost characteristics even without any change on your end. Every provider in the fallback chain (Bedrock Mantle → WebbyxAI/Claude Haiku → Gemini) still activates automatically on failure, so this is not a behavior change for reliability, only for which provider normally serves a healthy request. If you need the prior default back for your account, set llmProviderOverride explicitly — see Agents: LLM provider override.
  • Added: llm workflow nodes now support matchAnyOf — set it to the full list of valid labels on a classification node feeding a router/condition, and the executor will extract whichever candidate label appears earliest in the model’s raw response instead of trusting the model to emit only that word. Fixes a real reliability gap: some models (particularly smaller/faster ones) occasionally ignore a “respond with ONLY one word” instruction and answer the user’s actual question instead, which previously caused a silent misroute to the router’s defaultTarget with no error anywhere. See LLM Node: matchAnyOf.
  • Fixed: deactivateChannelConnector now releases the connector’s claim on its platform identifier (bot id, phone number, etc.) instead of holding it indefinitely. Previously, once any connector claimed an identifier, no connector — including a reactivated version of the same one — could ever claim it again without direct database intervention, since deactivating only paused the connector without freeing the identifier. If you deactivate a connector and later want to reactivate it, call updateChannelConnectorCredentials again first — a bare activateChannelConnector on a connector with no externalIdentifier now fails clearly instead of assuming the old registration still holds.
  • Added: sendMessage/sdkSendMessage now accept an optional clientTurnId string, echoed back verbatim on the AiResponseEvent produced for that turn. Useful for matching a specific reply to the message that produced it, especially if your integration sends more than one message before waiting for a response — previously there was no way to correlate a reply to a specific turn beyond assuming strict send/receive ordering. Fully optional and backward compatible: omit it and nothing changes. See Sessions: sdkSendMessage and Events & Subscriptions.
  • Added (pilot): Agents and tenants can now override which language model provider answers conversation turns, via llmProviderOverride (BEDROCK_MANTLE, WEBBYXAI_HAIKU, or GEMINI_DIRECT) and, for BEDROCK_MANTLE specifically, llmModelOverride to pick a specific model from AWS Bedrock’s open-weight catalog. Currently in limited pilot availability — contact your Wetel representative to enable this for your account. Every agent is unaffected unless both fields are explicitly set; any misconfigured or unavailable override falls back to the platform default automatically. See Agents: LLM provider override.
  • Added: Channel Connectors — a packaged, zero-code way to connect an Agent directly to Telegram. Five new operations: createChannelConnector, updateChannelConnectorCredentials, activateChannelConnector, deactivateChannelConnector, and listChannelConnectors. Activation runs a live health check against the platform and automatically registers the webhook for you — no manual setup step. WhatsApp, Slack, and Lark are valid ChannelType values but not yet implemented; every operation against one currently returns a clear error rather than a silent no-op. See Channels and Headless Agents: Telegram is now a packaged, zero-code connector.
  • Fixed (docs correction + real behavior): register now always assigns a new tenant to a freshly registered user automatically — this page previously (and correctly, at the time) documented that a fresh signup starts with no tenant (myTenant returning null). If your integration was creating or assigning a tenant itself immediately after calling register, check myTenant first — one already exists by the time register returns, and creating a second is unnecessary. login also self-heals this for any pre-existing account that was created before this change and never got a tenant assigned.
  • Added: ExportRecordDto.status now has a FALLBACK_READY value, alongside the existing PENDING/RENDERING/READY/FAILED. When a requestStudyKitExport render fails (including timing out) and a fallback document is configured server-side, the export settles into FALLBACK_READY instead of FAILED — downloadUrl is populated with a fresh presigned URL to a fixed substitute document (7-day expiry, vs. the normal READY path’s 24h), and errorMessage is also populated so you can tell this happened. Treat FALLBACK_READY as a terminal, download-available state alongside READY when polling exportRecord. See Evaluation & Export: The ExportRecordDto type.
  • Added: llm workflow nodes now support excludeHistory — set true on a narrow “extract one value from the current message” node to exclude it from receiving the session’s conversationHistory in its underlying model call. Fixes a real reliability gap: once a session has a few tool-backed turns behind it, structured JSON response envelopes accumulate in that history, and a narrow extraction node with no legitimate use for that context can start echoing JSON-shaped output back instead of the bare value its own prompt asked for. See LLM Node: excludeHistory.
  • Added: llm workflow nodes with knowledgeBaseId set now support allowGeneralKnowledgeFallback — softens the default retrieval-augmentation directive (“answer using ONLY this reference material”) to “use it if relevant, otherwise answer from general knowledge and say so,” for nodes whose own prompt already has an explicit fallback instruction. Without this, a node like a study-kit generator would get overridden into refusing to answer whenever retrieval returned even a loosely-related chunk. See LLM Node: allowGeneralKnowledgeFallback.
  • Added: a new jsonString Handlebars helper, usable inside any text-field template (a response node’s messageTemplate, an llm node’s prompt, etc.) to safely embed a variable inside a hand-authored JSON string literal. Closes a gap in the existing text-field/JSON-field escaping split for workflows that render a JSON envelope as their outgoing message text. See Best Practices: building a hand-authored JSON envelope inside a text field.
  • Fixed: an agent with both openingGreeting set and a workflowId attached could produce a double greeting on session start — the static greeting racing against the workflow’s own first response. openingGreeting is now always skipped for any agent with a workflow attached (it was already effectively unreliable in this configuration; this makes the behavior deterministic instead of a race). If your agent needs a specific first-turn behavior and has a workflow attached, build it into the workflow graph itself. See Agents: openingGreeting.
  • Corrected (docs): Events & Subscriptions had the sessionEvents subscription’s $sessionId variable typed as Int! in its own example — wrong, it’s ID!. Every other session-related field in the API (SdkSendMessageInput.sessionId, etc.) genuinely is Int!, which makes Int! the natural but incorrect guess here; a variable declared with the wrong named type fails GraphQL schema validation and the subscription never opens, with no reply ever arriving. Two real integrations hit this before it was caught. No API change — the schema was always ID!; only the docs’ own example was wrong. Added an explicit callout so this doesn’t recur.
  • Added (docs): Avatar rendering options now explains that HOSTED_API is a rendering tier with more than one possible underlying provider (a higher-fidelity photorealistic option and a lower-latency/lower-cost lightweight option), selected server-side per agent — your integration code doesn’t need to know or branch on which one is active.
  • Added (docs): Headless Agents now includes a full worked example of bridging a messaging channel (Telegram, as the concrete case) to a headless Wetel session — long-polling instead of a webhook (no public URL needed), session-per-conversation mapping, and the subscribe-before-send pattern required since sdkSendMessage only returns an ack.
  • Clarified (docs): Backend-Proxied Relay Architecture now makes explicit that adopting this pattern does not, by itself, persist any messages to your database — it only gives you the place to add that write. A real integration built this pattern’s transport (subscription + SSE relay) correctly but never added the actual persistence, then assumed it was “basically already happening” since every message visibly passed through their backend. No API changes — this is a docs clarification to prevent the same assumption elsewhere. See the new callout under “What this pattern does not give you for free.”
  • Added (docs): Workflows Overview now includes a comparison to general-purpose state-machine libraries (XState and statecharts in general) — what Wetel’s workflow graph gives you for free (built-in LLM/tool/webhook node types, hosted cross-turn persistence) versus what it intentionally doesn’t attempt (hierarchical/parallel states, offline simulation tooling, portability outside the platform). No API changes — this is purely a docs addition for anyone evaluating the platform against familiar state-machine tooling.
  • Added (docs): Backend-Proxied Relay Architecture — a documented alternative to the default browser-direct integration. Your backend holds the sessionEvents subscription itself and relays replies to your frontend over a channel you control, so the browser never talks to Wetel directly. No API changes — this documents an architecture choice available today using existing mutations, for integrations that want backend-native message persistence, moderation before delivery, or centralized reconnection handling.
  • Added: Agents can now optionally use a hosted photorealistic avatar backend via the avatarBackend configuration field, as an alternative to the default client-side 3D avatar. Set avatarBackend: HOSTED_API when creating or updating an agent to enable this option. Currently in limited pilot availability — contact your Wetel representative to enable for your account. See Agents: avatarBackend and Avatar Rendering Options for details.
  • Added: Barge-in/interrupt now works on the pilot HOSTED_API avatar backend, not just the default 3D avatar — previously only <vai-avatar>’s TalkingHead-based (CLIENT_3D) path supported stopping the avatar mid-reply. Also added two new <vai-avatar> methods: handleBargeIn() for triggering an interrupt directly (e.g. an explicit “stop” button, independent of voice detection) and sendText(text) for sending a typed message through the same pipeline as a spoken one. See Avatar: handleBargeIn() and Avatar: sendText().
  • Fixed: Interrupting an agent mid-reply could permanently break turn-based routing for the rest of that session — every subsequent message received the same generic fallback reply regardless of content, with no error surfaced anywhere. This affected any agent whose workflow uses a ROUTER node keyed on turn number. No API surface changed; if you build workflows with turn-count-based routing and use interruptSession, this is now safe.
  • Added: AiResponseEvent now has a turnComplete field, alongside the existing isFinal. If your agent is workflow-driven and can speak multiple lines in one turn (e.g. a workflow with two response nodes in sequence), you previously had no way to distinguish “this line is done” from “the whole turn is done” — every line arrived with isFinal: true on its own. Watch turnComplete instead of isFinal if you need to know when the agent has genuinely finished replying. See Events & Subscriptions for the full explanation. isFinal is unchanged — this is additive, not a breaking change.
  • Added: sessionsByExternalCustomerId(externalCustomerId, first, after) — a new API-key-tier query for re-pulling all Sessions matching your own clientExternalId/clientId, Relay-paginated. Useful as a backup/re-sync path if you lose local state and never persisted session ids yourself. See Sessions: API-key-tier customer lookup queries.
  • Added: customerUsageSummary(externalCustomerId) — a new API-key-tier query returning rolled-up tokensIn/tokensOut/costUsd across every Session for one customer. Same page linked above.
  • Added: MessageDto now has tokensIn, tokensOut, and costUsd fields, populated per message (0 for USER messages and for workflow response nodes with no LLM call behind them). These are character-based estimates, not exact billing figures.
  • Added: Named API keys (createNamedApiKey(name) / revokeNamedApiKey(id)) — create multiple independently-revocable keys per tenant instead of sharing one generateApiKey key across every integration. Revoking one no longer affects any other. Fully additive — your existing generateApiKey key keeps working unchanged if you don’t use this. See Authentication: Named API keys.
  • Fixed: interruptSession now accepts either a dashboard JWT or the SDK avatarToken from sdkStart — previously it only accepted a JWT, which meant the <vai-avatar> embed SDK’s own barge-in call could never authenticate. If you’ve built directly against interruptSession using the avatarToken and seen an Unauthorized error, this is now fixed.
  • Fixed: the sessionEvents subscription’s $sessionId variable type is ID!, not Int! — if you hand-wrote this subscription against an older code sample, update the variable type or the subscription will fail to establish with no visible error (the triggering mutation still returns success, so this looked exactly like “no reply ever arrives”).
  • Fixed: publishing a workflow with two nodes sharing the same node ID, or with a node that has no path from START, is now rejected with a clear error at publish time — previously the duplicate silently became dead, unexecuted data, and the orphan just sat in the graph doing nothing. A workflow with an intentional loop-back (e.g. a router retrying a step) is unaffected — cycles are a supported pattern, not something this check rejects.
  • Fixed: a workflow condition node with no matching true/false outgoing edge now fails the run with a clear error, instead of silently stopping with no signal at all.
  • Fixed: publishWorkflow now validates every node’s configuration before publishing (previously only saving a draft did this) — a workflow can no longer go live with invalid node config.
  • Added: webhook nodes now support a configurable timeoutMs, matching tool/action nodes.
  • Improved: MCP connector failures are now classified (DNS resolution failed, connection refused, connection timed out, TLS handshake failed, authentication failed) instead of a single generic, undifferentiated error.
  • Fixed: sending a message to an ended session now returns a clear error instead of a generic server error.
  • Fixed: an internal inconsistency in how session-related mutations typed their ID argument has been standardized.
  • Fixed: a workflow node’s outputVar can no longer silently collide with a reserved internal name — this is now rejected at save time with a clear error, instead of corrupting the running session silently.

Embeddings API, external conversation sync, and AI-generated workflow drafts all shipped in the weeks prior to this changelog’s start — see the relevant guide pages for current behavior. A full historical changelog before this date isn’t available yet.