Skip to content

Recipe: Read Many Photos in One Node

This recipe builds an agent that answers questions about photos. The user sends one message with several [image:<url>] markers, and one llm node fetches, shrinks and reads up to 40 of them. There is no per-image fetch step and no sub-agent to wait for.

Every request and response below was run against a real Wetel environment on 2026-10-06 and then shortened (long text cut, ids and secrets replaced by placeholders). Response bodies are structurally exact. Placeholders such as <AGENT_ID> and <WORKFLOW_ID> stand for plain integers in the real requests.

Set the API host once:

Terminal window
export WETEL_API="https://<your Wetel API host>/graphql"

Different calls take different credentials. Mixing them up answers Unauthorized (see Troubleshooting).

CallCredential
createAgent, updateAgent, deleteAgent, createWorkflow, updateWorkflow, publishWorkflow, deleteWorkflow, revokeNamedApiKey, workflowRuns, sessionAuthorization: Bearer <JWT> (a dashboard login, from the login mutation)
sdkStart, runWorkflowTask, workflowTaskOutcomeX-Api-Key: <API_KEY>
sdkSendMessageAuthorization: Bearer <AVATAR_TOKEN> (returned by sdkStart)

Every request also carries x-huat-platform: customer. See Authentication for how to obtain each credential.

createAgent needs name, position, personaPrompt and useCase. voiceTier is optional (it defaults to STANDARD).

Terminal window
curl -s "$WETEL_API" \
-H 'content-type: application/json' \
-H 'x-huat-platform: customer' \
-H 'Authorization: Bearer <JWT>' \
-d '{"query":"mutation CreateAgent($input: CreateAgentInput!) { createAgent(input: $input) { id name } }","variables":{"input":{"name":"Photo Reader","position":"Photo analyst","personaPrompt":"You answer questions about photos the user sends.","useCase":"SUPPORT"}}}'
{ "data": { "createAgent": { "id": "<AGENT_ID>", "name": "Photo Reader" } } }

Step 2: Create the workflow and add the graph

Section titled “Step 2: Create the workflow and add the graph”

createWorkflow makes an empty draft (name and description only); the graph goes in with updateWorkflow.

mutation CreateWorkflow($input: CreateWorkflowInput!) {
createWorkflow(input: $input) {
id
name
isPublished
}
}
{
"input": {
"name": "Read many photos",
"description": "One llm node reads every [image:<url>] marker in the message"
}
}
{
"data": {
"createWorkflow": {
"id": "<WORKFLOW_ID>",
"name": "Read many photos",
"isPublished": false
}
}
}

Now the graph. start to llm to response is complete; no end_session node is needed.

mutation UpdateWorkflow($input: UpdateWorkflowInput!) {
updateWorkflow(input: $input) {
id
version
isPublished
}
}
{
"input": {
"id": "<WORKFLOW_ID>",
"nodes": [
{
"id": "start",
"type": "start",
"label": "Start",
"config": {},
"position": { "x": 0, "y": 0 }
},
{
"id": "look",
"type": "llm",
"label": "Read the photos",
"config": {
"imageMarkerHistory": 40,
"promptTemplate": "Describe only what is visible in the attached images. Never infer anything from file names or URLs. If an image could not be loaded, say so.\n\nUser: {{userMessage}}",
"outputVar": "imageDescription",
"streamOutput": false
},
"position": { "x": 240, "y": 0 }
},
{
"id": "reply",
"type": "response",
"label": "Reply",
"config": { "messageTemplate": "{{imageDescription}}" },
"position": { "x": 480, "y": 0 }
}
],
"edges": [
{ "id": "e1", "source": "start", "target": "look" },
{ "id": "e2", "source": "look", "target": "reply" }
]
}
}
{
"data": {
"updateWorkflow": {
"id": "<WORKFLOW_ID>",
"version": 2,
"isPublished": false
}
}
}

imageMarkerHistory: 40 attaches the images named by the last 40 [image:<url>] markers in the user’s messages. streamOutput: false is set because the reply goes through a response node.

Note that updateWorkflow accepts a graph with no start node as a draft; only publishWorkflow rejects it (Workflow must have a START node before publishing).

Section titled “Step 3: Publish, link the agent, prove it runs”

A draft that was never published runs zero nodes, with no error. Publish, then link the agent to the workflow.

mutation PublishWorkflow($id: Int!) {
publishWorkflow(id: $id) {
id
isPublished
version
}
}
{
"data": {
"publishWorkflow": {
"id": "<WORKFLOW_ID>",
"isPublished": true,
"version": 3
}
}
}
mutation LinkAgent($id: ID!, $input: UpdateAgentInput!) {
updateAgent(id: $id, input: $input) {
id
workflowId
}
}
{ "id": "<AGENT_ID>", "input": { "workflowId": "<WORKFLOW_ID>" } }
{
"data": {
"updateAgent": { "id": "<AGENT_ID>", "workflowId": "<WORKFLOW_ID>" }
}
}

There is no field that shows the published snapshot, so the reliable check that the workflow is live is a plain greeting. Start a session (Step 4 shows the call), send Hello! What can you do? and confirm a reply arrives. sdkSendMessage returns true at once; that is an acknowledgement, not the reply.

To poll for it, read the run with a JWT until status is COMPLETED:

query RunsForSession($sessionId: Int!) {
workflowRuns(sessionId: $sessionId) {
id
status
errorMessage
}
}
{
"data": {
"workflowRuns": [
{ "id": "<RUN_ID>", "status": "COMPLETED", "errorMessage": null }
]
}
}

The reply text is in the stored messages (an end-user integration receives it on the sessionEvents subscription instead; the query below is for scripts):

query Messages($id: ID!) {
session(id: $id) {
messages {
role
text
}
}
}

One session per conversation. Call it from your backend with the API key; it returns the session id and the short-lived avatarToken.

Terminal window
curl -s "$WETEL_API" \
-H 'content-type: application/json' \
-H 'x-huat-platform: customer' \
-H 'X-Api-Key: <API_KEY>' \
-d '{"query":"mutation StartSession($input: SdkStartInput!) { sdkStart(input: $input) { sessionId avatarToken graphqlEndpoint } }","variables":{"input":{"agentId":"<AGENT_ID>","clientId":"photo-demo-1"}}}'
{
"data": {
"sdkStart": {
"sessionId": "<SESSION_ID>",
"avatarToken": "<AVATAR_TOKEN>",
"graphqlEndpoint": "https://<your Wetel API host>/graphql"
}
}
}

The photos travel as [image:<url>] markers inside the one message text.

Terminal window
curl -s "$WETEL_API" \
-H 'content-type: application/json' \
-H 'x-huat-platform: customer' \
-H 'Authorization: Bearer <AVATAR_TOKEN>' \
-d '{"query":"mutation SendMessage($input: SdkSendMessageInput!) { sdkSendMessage(input: $input) }","variables":{"input":{"sessionId":"<SESSION_ID>","text":"Which of these is a mountain?\n[image:https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=2000&q=80]\n[image:https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=2000&q=80]\n[image:https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=2000&q=80]"}}}'
{ "data": { "sdkSendMessage": true } }

nodeTrace has one entry per node; the llm entry carries imageFetch. This needs the JWT.

query RunTrace($sessionId: Int!) {
workflowRuns(sessionId: $sessionId) {
id
status
errorMessage
nodeTrace
}
}

Shortened response (the start and reply entries are cut):

{
"nodeId": "look",
"status": "COMPLETED",
"nodeType": "llm",
"outputVar": "imageDescription",
"imageFetch": {
"hosts": ["images.unsplash.com"],
"skips": [],
"shrunk": 3,
"skipped": 0,
"attached": 3,
"requested": 3,
"bytesAfter": 817857,
"totalBytes": 817857,
"bytesBefore": 1472507,
"passedThrough": 0,
"totalMaxBytes": 18000000,
"historySkipped": 0,
"imageVarsCount": 0,
"perImageMaxBytes": 3932160,
"droppedByCountCap": 0,
"requestedThisTurn": 3
},
"tokensUsed": 1035,
"outputValue": "The **first image** is a mountain. It shows a scenic valley landscape with tall rocky mountain formations ... The second image is ocean waves ... the third image is a forest path ... neither of these are mountains."
}

All three photos were fetched and shrunk (1,472,507 bytes of originals became 817,857). The same text is in session(id) { messages { role text } }. The llm node took about 7.6 seconds in this run.

Send the same question with the middle URL replaced by one that returns 404. The run still completes, the model is told one image could not be loaded, and the trace says why:

"imageFetch": {
"hosts": ["images.unsplash.com"],
"skips": [{ "host": "images.unsplash.com", "reason": "http-404" }],
"shrunk": 2,
"skipped": 1,
"attached": 2,
"requested": 3,
...
}

The answer begins Based on the images that loaded: and describes the two that did. Set imageUrlsFailPolicy: "fail" on the node if you would rather fail the run than answer from a partial set.

When the URLs do not come from the conversation (a batch job, a form submission), put them in a context variable and name it with imageUrlsVar. The node config changes; the rest of the graph is the same:

{
"id": "look",
"type": "llm",
"label": "Read the photos",
"config": {
"imageUrlsVar": "photoUrls",
"promptTemplate": "Which of the attached photos shows a mountain? Describe only what is visible in the attached images. Never infer anything from file names or URLs. If an image could not be loaded, say so.",
"outputVar": "imageDescription",
"streamOutput": false
}
}

Create a second agent and workflow with this graph, publish it and link it as in Steps 1 to 3, then run it headlessly with runWorkflowTask and the API key. variables must be flat scalars, so the URL list goes in as a JSON string (a newline-separated string works too), not as an array:

Terminal window
curl -s "$WETEL_API" \
-H 'content-type: application/json' \
-H 'x-huat-platform: customer' \
-H 'X-Api-Key: <API_KEY>' \
-d '{"query":"mutation RunTask($input: RunWorkflowTaskInput!) { runWorkflowTask(input: $input) { accepted sessionId workflowId } }","variables":{"input":{"agentId":"<AGENT2_ID>","externalConversationId":"photo-task-1","variables":{"photoUrls":"[\"https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=2000&q=80\",\"https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=2000&q=80\",\"https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=2000&q=80\"]"}}}}'
{
"data": {
"runWorkflowTask": {
"accepted": true,
"sessionId": "<SESSION_ID>",
"workflowId": "<WORKFLOW2_ID>"
}
}
}

accepted: true means queued. Read the outcome with the API key (unlike workflowRuns, this does not need a JWT):

query Outcome($sessionId: Int!) {
workflowTaskOutcome(sessionId: $sessionId) {
status
replies {
text
}
runs {
id
status
errorMessage
}
}
}

workflowTaskOutcome returns replies and run status but not nodeTrace; to see imageFetch for a task run, read workflowRuns with a JWT.

imageUrlsVar only works in the workflow that owns the variable: a child session started by a sub_agent node sees only its contextTemplate, not the parent’s variables.

Step 8: Run the same node on a Gemini model

Section titled “Step 8: Run the same node on a Gemini model”

Only the llm node config changes. modelOverride names the model and the provider follows:

"config": {
"imageMarkerHistory": 40,
"promptTemplate": "...",
"outputVar": "imageDescription",
"streamOutput": false,
"modelOverride": "gemini-3.5-flash"
}

Send it with updateWorkflow, then publish again (sessions run the published snapshot, so every edit needs a publish). The same three-photo message on a new session answered correctly, and the trace shows the limits that apply on this route: "perImageMaxBytes": 5000000, "totalMaxBytes": 24000000.

mutation DeleteAgent($id: ID!) {
deleteAgent(id: $id)
}

deleteAgent and deleteWorkflow(id: Int!) soft-delete and return true. revokeNamedApiKey(id: Int!) revokes a named API key immediately and permanently. All three need the JWT. Sessions and their messages cannot be deleted: there is no delete-session mutation.

The imageFetch object on the llm entry of nodeTrace holds counts and sizes only, never a URL path or query string:

FieldMeaning
requested, requestedThisTurnURLs asked for in total, and in this turn
attachedImages actually sent to the model
skipped, historySkippedImages left out, and how many of those came from older messages
droppedByCountCapImages dropped by the 40-image limit before fetching
shrunk, passedThroughImages that were shrunk, and images already within limits that went through untouched
bytesBefore, bytesAfter, totalBytesOriginal size, size after shrinking, total attached
perImageMaxBytes, totalMaxBytesThe limits applied on this route
hostsHosts of the attached images
skipsOne { host, reason } per skipped image

Skip reasons you will meet: http-404 (and other http-<status> codes), deadline (the shared 90-second fetch-and-shrink deadline ran out), too-large (original over 20 MB), too-many-pixels (over 36 megapixels), host-not-allowed, not-https, private-address, timeout, not-an-image, total-size-cap, fetch-failed. The full list is in the LLM node reference.

The model is told only how many requested images failed ([note: 1 of 3 images could not be loaded]), not which. Read skips to find out.

  • 40 images per node from the URL options (imageMarkerHistory is 1 to 40); imageVars keeps its cap of 20.
  • Originals up to 20 MB are fetched and shrunk to a long edge of about 1568 px; over 20 MB is skipped (too-large), and so is anything over 36 megapixels (too-many-pixels).
  • After shrinking: 5 MB per image (about 3.9 MB on the Claude route) and 18 MB total per call, or 24 MB when the node pins a Gemini model.
  • A hard 90-second deadline covers the whole fetch-and-shrink phase; each server shrinks 3 images at a time.

Measured with 40 large phone photos (about 253 MB of originals, 12.6 MB after shrinking):

RunImages attachedEnd to end
Alone40 of 40about 54 s
2 overlapping runs40 and 40about 113 s each
4 overlapping runs38 to 40109 to 129 s each

The losses under heavy overlap are images skipped with reason deadline. Every user message re-fetches the images of the last N markers, so keep imageMarkerHistory as small as the question needs.

An older pattern fetched each photo with an action node (fetchAsBase64Var), fed imageVars, and sometimes waited on a sub-agent for the reading. To migrate, delete the fetch and extract nodes and the sub-agent wait, and set imageMarkerHistory (or imageUrlsVar) on the one llm node. If you keep a sub-agent, the Master must put the [image:<url>] lines into the opening message it sends, because a child sees only its contextTemplate.

GraphQL errors come back with HTTP 200 and an errors array; read extensions.code, not the HTTP status.

What happenedMessageCode
createAgent with an API key instead of a login tokenUnauthorizedUNAUTHENTICATED
sdkSendMessage with the API key instead of the avatarTokenMissing or malformed Authorization headerUNAUTHENTICATED
workflowRuns with the avatarToken or the API keyUnauthorizedUNAUTHENTICATED
Missing x-huat-platform on a real operationPlatform is not specified!FORBIDDEN
CUSTOM agent without evaluationPromptTemplateCUSTOM use case requires evaluationPromptTemplateBAD_REQUEST
imageMarkerHistory out of rangeLLM node "look" has an invalid imageMarkerHistory — must be an integer from 1 to 40BAD_USER_INPUT
publishWorkflow on a graph with no start nodeWorkflow must have a START node before publishing (lists the node types it saw)BAD_REQUEST
Node type in uppercase ("START") at save or publishnode n1: type "START" must be lowercase: "start"BAD_USER_INPUT
Any input-validation failurethe constraint messages joined with ; , e.g. name should not be empty (was Bad Request Exception before 2026-10-07)BAD_REQUEST

The imageMarkerHistory error is a validation error (BAD_USER_INPUT, returned by updateWorkflow or publishWorkflow); fix the value rather than retrying. It used to answer INTERNAL_SERVER_ERROR. The other image options are checked the same way; see LLM node.

  1. The model sees the marker text and URLs. Use opaque file names (not damaged-brake-pad.jpg) and keep the guarded prompt from Step 2 (“Never infer anything from file names or URLs”), or the model may answer from the name instead of the picture.

  2. Position after a skip is unreliable. The model is told how many failed, not which, and can name the wrong picture. If order matters, read skips in the trace.

  3. Signed URLs are stored as sent, but shown redacted. The message text is stored exactly as you send it so the node can fetch the image, and read back through the API (session messages, workflowRuns context, the portal) the marker appears as [image:https://host/path?[redacted]] (query, fragment and userinfo removed). Bare URLs in prose are not rewritten, and your own conversation-sync integration still receives the original text. The node never writes URLs to logs or the trace. Prefer short-lived signatures.

  4. Models often wrap JSON in a markdown code fence. If you ask the node for JSON, say Output raw JSON only, no markdown code fence in the prompt (on Haiku the fence appeared on every call in a test) and strip a leading and trailing fence in your parser. Do not use matchAnyOf on such a node: it replaces the output with the matched label, so it is only for classification nodes whose output is a single label.

  5. Setting only llmModelOverride on the agent works. Since 2026-10-07 the agent runs on the provider that owns the model, so a Gemini model name no longer needs llmProviderOverride to read images. A model no enabled provider serves, or a provider that contradicts the model, is refused by createAgent/updateAgent with BAD_REQUEST and a message that lists the allowed choices.

  6. Your SDK credential cannot see which model answered. The avatar token and API key used with sdkStart and sdkSendMessage cannot read workflowRuns, node traces or the serving model; those need a signed-in dashboard or portal session (JWT). The per-model call counts in the breakdown of apiUsageSummary show which models your tenant used.