Skip to content

Recipe: Channel Agent with File Intake and a Partner Write-Back

This content is not available in your language yet.

This recipe builds a headless, channel-native agent: a user finds it on a messaging platform (Telegram today — see Channels) with no app to install and no dashboard for them to see, holds a multi-turn conversation that collects structured information, optionally sends a file, and the agent writes the outcome to a real partner system before replying with a close-out message.

The worked example throughout is real, not illustrative: an HR screening bot that qualifies job candidates over Telegram, collects a résumé as a PDF, and files it against a live Nortia recruitment record via a certified operation. It’s generalized here so the same shape works for a different channel or a different partner — an OmniChat support-intake bot that collects a ticket description and files it via OmniChat’s own API, an aisCRM lead-qualification bot that collects contact details and creates a CRM record, or any “collect info from a user over a chat channel, optionally take a file, call a partner API with the result” agent.

Architecture — three systems, one conversation

Section titled “Architecture — three systems, one conversation”
flowchart LR
U["End user<br/>on the chat platform"]
GW["Channel Connector<br/>webhook, acknowledges instantly"]
WF["Wetel workflow<br/>conversation + file intake"]
PARTNER["Partner API<br/>certified operations"]
U -- "1. message / file" --> GW
GW -- "2. forwards, in the background" --> WF
WF -- "3. certified operation call" --> PARTNER
PARTNER -- "4. read/write confirmation" --> WF
WF -- "5. reply, whenever ready" --> GW
GW -- "6. sends reply" --> U

Step 2 is the one to get right first. The connector acknowledges the platform’s webhook immediately, then does the actual work — LLM calls, partner API round-trips — in the background. The end user’s platform (Telegram, WhatsApp, or any webhook-driven channel) never waits on the full reply; it arrives as a separate outbound message whenever it’s ready. A synchronous-await design that makes the platform’s own webhook wait on the full AI turn will eventually exceed that platform’s timeout, and the resulting retry can collide with your own duplicate-message protection — producing “no response, ever,” with no visible error anywhere. This is the single most consequential failure mode in a build like this; if you’re rolling your own bridge instead of using a packaged Channel Connector, see Headless Agents: worked example for the ack-first pattern.

Keep personaPrompt short and behavioral — tone, scope, what not to do — not a script of the exact conversation. The actual question sequence and branching logic live in the workflow graph, not the prompt; see Agents: the persona prompt for why step-by-step logic belongs in the workflow.

mutation CreateScreeningAgent {
createAgent(
input: {
name: "HR Screening Demo"
position: "Recruiter"
personaPrompt: "You are a friendly HR screening assistant, reachable only over Telegram. A candidate has messaged you directly — you have no other context about who they are yet. Your job: help them find or confirm a role, ask the standard screening questions one at a time, then collect their résumé as a PDF file. Be warm and conversational, never robotic — but stay focused on this task; do not answer unrelated questions at length."
useCase: INTERVIEW
voiceTier: STANDARD
}
) {
id
}
}

useCase: INTERVIEW selects the evaluation shape that runs if you attach an end_session node with runEvaluation: true — see Recipe: First-Round Candidate Screening Agent if a scored evaluation, rather than a partner write-back, is your primary goal; the two patterns compose fine in the same workflow if you need both.

Create and activate a Channel Connector pointed at this agent — for Telegram, that’s a bot token from @BotFather. Activation runs a live health check and registers the platform’s webhook automatically; there’s no manual webhook setup.

mutation CreateConnector($input: CreateChannelConnectorInput!) {
createChannelConnector(input: $input) {
id
status
}
}
{ "input": { "agentId": 46, "channelType": "TELEGRAM" } }

Then updateChannelConnectorCredentials with the bot token, then activateChannelConnector. Once status is ACTIVE, any message sent to that bot reaches this agent.

Test the workflow logic directly first, before testing on the real channel. Drive it via sdkStart/sdkSendMessage/workflowRuns(sessionId){nodeTrace,status} (see Headless Agents) to isolate “does the workflow logic work” from “does the channel integration work” — these are genuinely separate failure surfaces, and conflating them wastes real debugging time. Once the backend path is confirmed healthy, test on the real channel with a message sent immediately, then again after waiting over an hour — session- and token-expiry bugs typically only show up on the second case.

Five conceptual stages, built as a single graph that re-evaluates from start on every incoming message:

flowchart TD
A["Greeting & role<br/>selection"]
B{"Asking for info,<br/>or ready to<br/>continue?"}
C["Answer / list<br/>live partner data"]
D["Q&A — ask one<br/>question at a time"]
E{"File attached<br/>& is the right<br/>type?"}
F["Reject & ask<br/>to resend"]
G["Write result<br/>to partner"]
H["Read back &<br/>finalize"]
I(["Closing message"])
A --> B
B -- "asks for info" --> C --> A
B -- "answering / providing info" --> D
D -- "file attached" --> E
E -- "no / wrong format" --> F --> D
E -- "yes" --> G --> H --> I

3a. Fetch live partner data before the conversation starts

Section titled “3a. Fetch live partner data before the conversation starts”

The very first real node after start should be an action node fetching whatever live reference data the conversation needs (open job postings, available products, valid categories) — before any LLM node runs. Never let a conversational llm node improvise data it doesn’t actually have; every downstream prompt that needs this data should read it via {{jsonString fetchedVar}} (see the gotcha below), never invent a plausible-sounding answer.

If your channel supports file uploads, an incoming message can carry the [[WETEL_ATTACHMENT url="..." mime="..." filename="..."]] marker as a prefix. As of 2026-09-10 this is parsed server-side, before your workflow runs — no extraction node needed. Every run’s context already carries attachmentUrl/attachmentMime/attachmentFilename (empty strings when there’s no attachment), so the graph goes straight from your data-fetch node into a condition node:

{
"id": "route_attachment",
"type": "condition",
"label": "Has Attachment?",
"config": { "expression": "attachmentUrl != \"\"" },
"position": { "x": -200, "y": 360 }
}

Use the flat attachmentUrl/attachmentMime variables (not the nested attachment object) in this expression and in fetchAsBase64Var below — see Channels: attachment markers for why.

An intent-classification llm node with matchAnyOf set to your fixed intent labels, feeding a router node, dispatching between “answer a general question about the live data” and “continue the structured Q&A” — each branch ending in its own llm node (reading the live data from Step 3a via {{jsonString fetchedVar}}) followed by a response node that actually sends the reply. A response node with no further outgoing edge is how a turn ends while the session stays open for the next message — see Workflows Overview: worked example.

3d. File validation before you touch the file at all

Section titled “3d. File validation before you touch the file at all”

A condition node checking attachmentMime against your accepted type(s) (attachmentMime == "application/pdf"), with the false branch replying with a rejection message and looping back into the Q&A stage — never call fetchAsBase64Var on a file you haven’t validated yet.

3e. Write to the partner, then read back what you need

Section titled “3e. Write to the partner, then read back what you need”
{
"id": "fetch_file_base64",
"type": "action",
"label": "Fetch File (base64)",
"config": { "outputVar": "fileBase64", "fetchAsBase64Var": "attachmentUrl" },
"position": { "x": -100, "y": 500 }
}

Then the actual write — a certified operation if your partner is OmniChat, Nortia, or aisCRM, or a hand-authored argsTemplate against a registered Custom Action otherwise:

{
"id": "submit_to_partner",
"type": "action",
"label": "Submit Resume (REST)",
"config": {
"outputVar": "submitResult",
"timeoutMs": 20000,
"argsTemplate": "{\"job_posting_id\": {{findId jobPostings.data \"title\" matchedRoleTitle \"id\"}}, \"file_name\": \"resume\", \"file_base64\": {{jsonString fileBase64}} }",
"customActionId": 13,
"certifiedOperationId": 172
},
"position": { "x": -100, "y": 580 }
}

A write operation’s own response may not carry everything you need for the next step. Submit Resume (REST)’s response has no application id to act on afterward — so this pattern adds a read-back: a second action node listing the partner’s records, then a narrow llm node (excludeHistory: true, extractFirstInteger: true) that finds the just-created record by an identifying field and extracts its real id, before a final write (e.g. an application status update) or a closing message. Check a certified operation’s actual response shape via certifiedOperation(id) before assuming you can chain straight from a write into the next step — you may need this extra read-back hop.

Full workflow JSON — copy-pasteable, adapted from a real, live-verified deployment

Section titled “Full workflow JSON — copy-pasteable, adapted from a real, live-verified deployment”

This {nodes, edges} shape (20 nodes, 18 edges) is adapted from the real Telegram + Nortia screening workflow described throughout this recipe, live-verified end to end — trimmed of one optional pipeline step (a screening-outcome PASS/HOLD/REJECT classifier feeding a decision downstream of n_extract_role) that’s specific to a scored-outcome use case rather than this recipe’s own file-intake-and-write-back focus; add an llm node with matchAnyOf there if your own use case needs the same triage step. (The two attachment-extraction llm nodes present in earlier versions of this recipe are gone as of 2026-09-10 — the platform now parses the attachment marker into attachmentUrl/attachmentMime for you; see Step 3b.) Pass this as updateWorkflow’s nodes/edges fields — adjust customActionId/certifiedOperationId to your own tenant’s registered connection, and the prompt text/argsTemplate fields to your own data shape.

{
"nodes": [
{
"id": "n0",
"type": "start",
"label": "Start",
"config": {},
"position": { "x": 0, "y": 0 }
},
{
"id": "n_list_jobs",
"type": "action",
"label": "List Job Postings (REST)",
"config": {
"outputVar": "jobPostings",
"timeoutMs": 10000,
"argsTemplate": "{\"company_id\":20,\"status\":\"active\"}",
"customActionId": 12,
"certifiedOperationId": 171
},
"position": { "x": 0, "y": 100 }
},
{
"id": "n_route_attachment",
"type": "condition",
"label": "Has Attachment?",
"config": { "expression": "attachmentUrl != \"\"" },
"position": { "x": -200, "y": 360 }
},
{
"id": "n_intent",
"type": "llm",
"label": "Classify Intent",
"config": {
"outputVar": "intent",
"matchAnyOf": ["ASK_LISTING", "SCREENING"],
"streamOutput": false,
"promptTemplate": "Latest candidate message: \"{{userMessage}}\"\n\nClassify it as exactly one of two tokens:\nASK_LISTING — the candidate is asking what roles/jobs are open, or asking to see a list of postings\nSCREENING — anything else (naming a role, answering a screening question, general conversation)\n\nRespond with ONLY the single matching token, no other text."
},
"position": { "x": 200, "y": 200 }
},
{
"id": "n_dispatch",
"type": "router",
"label": "Intent Dispatcher",
"config": { "variable": "intent", "defaultTarget": "n_screening_llm" },
"position": { "x": 200, "y": 280 }
},
{
"id": "n_listing_llm",
"type": "llm",
"label": "List Open Roles",
"config": {
"outputVar": "listingReply",
"streamOutput": false,
"promptTemplate": "The candidate asked what roles are open. Here is the live job postings data (JSON, already fetched):\n{{jsonString jobPostings}}\n\nList the open role titles in a short, friendly message, and ask which one they would like to apply for."
},
"position": { "x": 400, "y": 360 }
},
{
"id": "n_listing_response",
"type": "response",
"label": "Deliver Listing",
"config": { "messageTemplate": "{{listingReply}}" },
"position": { "x": 400, "y": 440 }
},
{
"id": "n_screening_llm",
"type": "llm",
"label": "Screening Q&A",
"config": {
"outputVar": "screeningReply",
"streamOutput": false,
"promptTemplate": "You are screening a job candidate over Telegram, one question at a time. Here are the live job postings currently open (JSON, already fetched):\n{{jsonString jobPostings}}\n\nIf the candidate has not yet named or confirmed a specific role, help them pick one from the list above. If they ask what roles are open, list the titles from the data above.\n\nOnce a role is confirmed, ask the standard screening questions ONE AT A TIME, in order, never more than one per message — use the conversation history to see which have already been answered and continue from there, never repeat an already-answered question. Once all have been answered, thank the candidate and ask them to send their résumé as a PDF file as their next message. Keep every message short (2-4 sentences) and conversational — this is a chat, not a form."
},
"position": { "x": 200, "y": 360 }
},
{
"id": "n_screening_response",
"type": "response",
"label": "Deliver Screening Reply",
"config": { "messageTemplate": "{{screeningReply}}" },
"position": { "x": 200, "y": 440 }
},
{
"id": "n_check_mime",
"type": "condition",
"label": "Is PDF?",
"config": { "expression": "attachmentMime == \"application/pdf\"" },
"position": { "x": -200, "y": 440 }
},
{
"id": "n_reject_non_pdf",
"type": "response",
"label": "Reject Non-PDF Attachment",
"config": {
"messageTemplate": "Thanks for sending that — for this application I can only accept your résumé as a PDF file specifically (not a photo). Could you please send it as a PDF?"
},
"position": { "x": -400, "y": 520 }
},
{
"id": "n_fetch_resume",
"type": "action",
"label": "Fetch Resume (base64)",
"config": {
"outputVar": "resumeBase64",
"fetchAsBase64Var": "attachmentUrl"
},
"position": { "x": -100, "y": 520 }
},
{
"id": "n_extract_role",
"type": "llm",
"label": "Extract Confirmed Role",
"config": {
"outputVar": "matchedRoleTitle",
"streamOutput": false,
"promptTemplate": "You are a silent, internal pipeline step. This is NOT your turn to reply to the candidate — you must NOT generate a greeting, a thank-you, a confirmation, or any other conversational reply; a separate step already handles the actual message sent to them. Your ONLY job: read the conversation history and output EXACTLY one value — nothing else, not even a single extra word, emoji, or punctuation mark beyond the title itself. The valid values are the real job posting titles below (JSON, already fetched):\n{{jsonString jobPostings}}\n\nOutput the exact title the candidate has settled on applying for, copied verbatim from the data above."
},
"position": { "x": -100, "y": 600 }
},
{
"id": "n_submit_resume",
"type": "action",
"label": "Submit Resume (REST)",
"config": {
"outputVar": "submitResumeResult",
"timeoutMs": 20000,
"argsTemplate": "{\"company_id\": 20, \"job_posting_id\": {{findId jobPostings.data \"title\" matchedRoleTitle \"id\"}}, \"source\": \"wetel-telegram-screening\", \"file_name\": \"resume\", \"file_base64\": {{jsonString resumeBase64}} }",
"customActionId": 13,
"certifiedOperationId": 172
},
"position": { "x": -100, "y": 760 }
},
{
"id": "n_list_candidates",
"type": "action",
"label": "List Candidates (for status update)",
"config": {
"outputVar": "candidatesList",
"timeoutMs": 10000,
"argsTemplate": "{}",
"customActionId": 14,
"certifiedOperationId": 4
},
"position": { "x": -100, "y": 840 }
},
{
"id": "n_find_application_id",
"type": "llm",
"label": "Find Just-Submitted Application Id",
"config": {
"outputVar": "applicationIdToUpdate",
"streamOutput": false,
"excludeHistory": true,
"extractFirstInteger": true,
"promptTemplate": "You are a silent parsing step, not a conversational assistant. Below is a JSON array of candidate records, each with a full_name field and a nested current_application.id field. Find the record whose full_name matches the just-submitted candidate and output ONLY that record's current_application.id as a bare integer, nothing else. If no such record exists, output exactly: 0\n\nCandidates:\n{{jsonString candidatesList}}"
},
"position": { "x": -100, "y": 920 }
},
{
"id": "n_check_application_found",
"type": "condition",
"label": "Application Found?",
"config": { "expression": "applicationIdToUpdate != 0" },
"position": { "x": -100, "y": 1000 }
},
{
"id": "n_update_status",
"type": "action",
"label": "Update Application Status",
"config": {
"outputVar": "updateStatusResult",
"timeoutMs": 10000,
"argsTemplate": "{\"id\": \"{{applicationIdToUpdate}}\", \"action\": \"reject\", \"reason\": \"Screening demo — test submission, safe to discard.\", \"notifyCandidate\": false}",
"customActionId": 14,
"certifiedOperationId": 162
},
"position": { "x": -300, "y": 1080 }
},
{
"id": "n_closing_response",
"type": "response",
"label": "Closing Message",
"config": {
"messageTemplate": "Thank you — your application has been submitted. Our team will review it and follow up if there's a fit. Have a great day!"
},
"position": { "x": -300, "y": 1160 }
},
{
"id": "n_closing_response_no_status_update",
"type": "response",
"label": "Closing Message (status update skipped)",
"config": {
"messageTemplate": "Thank you — your application has been submitted. Our team will review it and follow up if there's a fit. Have a great day!"
},
"position": { "x": 100, "y": 1080 }
}
],
"edges": [
{ "id": "e0", "source": "n0", "target": "n_list_jobs" },
{
"id": "e1",
"source": "n_list_jobs",
"target": "n_route_attachment"
},
{
"id": "e4",
"label": "false",
"source": "n_route_attachment",
"target": "n_intent"
},
{
"id": "e5",
"label": "true",
"source": "n_route_attachment",
"target": "n_check_mime"
},
{ "id": "e6", "source": "n_intent", "target": "n_dispatch" },
{
"id": "e7",
"label": "ASK_LISTING",
"source": "n_dispatch",
"target": "n_listing_llm"
},
{ "id": "e8", "source": "n_listing_llm", "target": "n_listing_response" },
{
"id": "e9",
"source": "n_screening_llm",
"target": "n_screening_response"
},
{
"id": "e10",
"label": "false",
"source": "n_check_mime",
"target": "n_reject_non_pdf"
},
{
"id": "e11",
"label": "true",
"source": "n_check_mime",
"target": "n_fetch_resume"
},
{ "id": "e12", "source": "n_fetch_resume", "target": "n_extract_role" },
{ "id": "e13", "source": "n_extract_role", "target": "n_submit_resume" },
{ "id": "e14", "source": "n_submit_resume", "target": "n_list_candidates" },
{
"id": "e15",
"source": "n_list_candidates",
"target": "n_find_application_id"
},
{
"id": "e16",
"source": "n_find_application_id",
"target": "n_check_application_found"
},
{
"id": "e17",
"label": "true",
"source": "n_check_application_found",
"target": "n_update_status"
},
{
"id": "e18",
"label": "false",
"source": "n_check_application_found",
"target": "n_closing_response_no_status_update"
},
{ "id": "e19", "source": "n_update_status", "target": "n_closing_response" }
]
}

n_dispatch (the router node) has no explicit outgoing edge labeled SCREENING — its defaultTarget: "n_screening_llm" config handles that branch instead. A router’s default target covering an unlabeled classification value is a valid, supported pattern, not an oversight — see Router Node.

Once you’ve called updateWorkflow with this shape, remember publishWorkflow — see the gotcha below.

Gotchas — read before building one of these

Section titled “Gotchas — read before building one of these”
  • An unpublished workflow, or a draft edit you forgot to republish, executes zero nodes with no error. Editing a live workflow via updateWorkflow writes to the draft only — it does not take effect at runtime until you call publishWorkflow again. Patch a live bug, retest, and see the exact same broken behavior? Check whether you republished. See Workflows API: the two-call create pattern.
  • A raw context object interpolated with {{var}} renders [object Object], not JSON. Every prompt reading fetched data (like jobPostings above) must use {{jsonString var}} — see Best Practices: jsonString.
  • Never set both systemPrompt and promptTemplate on an llm nodepromptTemplate’s content silently never reaches the model. Put everything (persona framing and any {{ctxVar}} data) in promptTemplate alone. See LLM Node.
  • An extraction/classification node can answer the conversation’s intent instead of its own narrow instruction, especially on a smaller/faster model — use explicit negative framing plus a bounded, inline answer set. See Best Practices.
  • matchAnyOf/extractFirstInteger exist because raw model text isn’t 100% reliable for anything a router/condition node or a numeric argsTemplate field depends on — use them for every node in that position. See LLM Node.
  • A certified operation’s own response may not carry what you need for the next step — see Step 3e above; check verificationNote/response shape via certifiedOperation(id) before assuming you can chain directly.
  • A Handlebars expression’s closing }} sitting flush against a literal JSON } can misparse as }}}. Leave a space, or reorder the field — see Best Practices: the triple-brace gotcha.
  • A webhook-driven channel must acknowledge before doing slow downstream work — see the Architecture section above.
  • If you’re rolling your own bridge rather than using a packaged Channel Connector, neutralize [[ in any end-user-typed text before prepending your own marker line — otherwise a user can type a fake [[nortia:resumeUrl=...]]-shaped line that gets parsed as if the platform sent it. See Channels: attachment markers.
  • A cross-product credential can need a different token type for different surfaces of the same partner. If a partner’s REST-shaped operations start failing with an auth error despite “the same credentials working elsewhere,” don’t assume a config typo — check whether that surface needs a differently-scoped token than whatever’s already working for a different surface of the same partner. See Certified Operations and Custom Actions API: Credential sources.
  • Use an obviously-fake identity for any write that reaches a partner’s real system, and confirm in advance whether the partner has a way to clean up a test record. If not, end on a clearly terminal status and let the partner’s team know after the first live run.
  • Test prompts to send yourself, in order: a greeting; a question about the live data (triggers the listing branch); naming a specific item from that data (triggers the Q&A branch); answering the questions; attaching the wrong file type first (confirms the rejection path); then attaching the right type (triggers the write-back chain).
  • workflowRuns(sessionId){nodeTrace,status} shows you exactly which node ran, what it rendered, and what it returned — check this before assuming a live-channel failure is a channel bug rather than a workflow one.
  • Channels — the connector lifecycle, the attachment marker, and the /start reset pattern in full.
  • Certified Operations — the OmniChat/Nortia/aisCRM catalog this recipe’s write-back step draws from.
  • Action NodefetchAsBase64Var and certified-operation wiring in full.
  • LLM NodematchAnyOf, extractFirstInteger, excludeHistory config reference.
  • Workflow Best Practices — templating rules, the findId helper, and extraction-node framing.
  • Recipe: First-Round Candidate Screening Agent — the companion recipe for a scored, evaluation-driven interview, composable with this one.
  • Headless Agents — the roll-your-own bridge pattern for a channel not covered by a packaged connector yet.