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:
export WETEL_API="https://<your Wetel API host>/graphql"Who authenticates how
Section titled “Who authenticates how”Different calls take different credentials. Mixing them up answers Unauthorized (see Troubleshooting).
| Call | Credential |
|---|---|
createAgent, updateAgent, deleteAgent, createWorkflow, updateWorkflow, publishWorkflow, deleteWorkflow, revokeNamedApiKey, workflowRuns, session | Authorization: Bearer <JWT> (a dashboard login, from the login mutation) |
sdkStart, runWorkflowTask, workflowTaskOutcome | X-Api-Key: <API_KEY> |
sdkSendMessage | Authorization: Bearer <AVATAR_TOKEN> (returned by sdkStart) |
Every request also carries x-huat-platform: customer. See Authentication for how to obtain each credential.
Step 1: Create the agent
Section titled “Step 1: Create the agent”createAgent needs name, position, personaPrompt and useCase. voiceTier is optional (it defaults to STANDARD).
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).
Step 3: Publish, link the agent, prove it runs
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 } }}Step 4: Start a session
Section titled “Step 4: Start a session”One session per conversation. Call it from your backend with the API key; it returns the session id and the short-lived avatarToken.
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" } }}Step 5: Send three photos in one message
Section titled “Step 5: Send three photos in one message”The photos travel as [image:<url>] markers inside the one message text.
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 } }Step 6: Read the run and the trace
Section titled “Step 6: Read the run and the trace”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.
The dead-URL case
Section titled “The dead-URL case”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.
Step 7: The imageUrlsVar variant
Section titled “Step 7: The imageUrlsVar variant”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:
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.
Clean up
Section titled “Clean up”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.
How to read the trace
Section titled “How to read the trace”The imageFetch object on the llm entry of nodeTrace holds counts and sizes only, never a URL path or query string:
| Field | Meaning |
|---|---|
requested, requestedThisTurn | URLs asked for in total, and in this turn |
attached | Images actually sent to the model |
skipped, historySkipped | Images left out, and how many of those came from older messages |
droppedByCountCap | Images dropped by the 40-image limit before fetching |
shrunk, passedThrough | Images that were shrunk, and images already within limits that went through untouched |
bytesBefore, bytesAfter, totalBytes | Original size, size after shrinking, total attached |
perImageMaxBytes, totalMaxBytes | The limits applied on this route |
hosts | Hosts of the attached images |
skips | One { 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.
Limits to design around
Section titled “Limits to design around”- 40 images per node from the URL options (
imageMarkerHistoryis 1 to 40);imageVarskeeps 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):
| Run | Images attached | End to end |
|---|---|---|
| Alone | 40 of 40 | about 54 s |
| 2 overlapping runs | 40 and 40 | about 113 s each |
| 4 overlapping runs | 38 to 40 | 109 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.
Migrating from the imageVars extractor
Section titled “Migrating from the imageVars extractor”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.
Troubleshooting
Section titled “Troubleshooting”GraphQL errors come back with HTTP 200 and an errors array; read extensions.code, not the HTTP status.
| What happened | Message | Code |
|---|---|---|
createAgent with an API key instead of a login token | Unauthorized | UNAUTHENTICATED |
sdkSendMessage with the API key instead of the avatarToken | Missing or malformed Authorization header | UNAUTHENTICATED |
workflowRuns with the avatarToken or the API key | Unauthorized | UNAUTHENTICATED |
Missing x-huat-platform on a real operation | Platform is not specified! | FORBIDDEN |
CUSTOM agent without evaluationPromptTemplate | CUSTOM use case requires evaluationPromptTemplate | BAD_REQUEST |
imageMarkerHistory out of range | LLM node "look" has an invalid imageMarkerHistory — must be an integer from 1 to 40 | BAD_USER_INPUT |
publishWorkflow on a graph with no start node | Workflow must have a START node before publishing (lists the node types it saw) | BAD_REQUEST |
Node type in uppercase ("START") at save or publish | node n1: type "START" must be lowercase: "start" | BAD_USER_INPUT |
| Any input-validation failure | the 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.
Three things to know
Section titled “Three things to know”-
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. -
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
skipsin the trace. -
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,
workflowRunscontext, 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. -
Models often wrap JSON in a markdown code fence. If you ask the node for JSON, say
Output raw JSON only, no markdown code fencein 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 usematchAnyOfon such a node: it replaces the output with the matched label, so it is only for classification nodes whose output is a single label. -
Setting only
llmModelOverrideon the agent works. Since 2026-10-07 the agent runs on the provider that owns the model, so a Gemini model name no longer needsllmProviderOverrideto read images. A model no enabled provider serves, or a provider that contradicts the model, is refused bycreateAgent/updateAgentwithBAD_REQUESTand a message that lists the allowed choices. -
Your SDK credential cannot see which model answered. The avatar token and API key used with
sdkStartandsdkSendMessagecannot readworkflowRuns, node traces or the serving model; those need a signed-in dashboard or portal session (JWT). The per-model call counts in thebreakdownofapiUsageSummaryshow which models your tenant used.
See also
Section titled “See also”- LLM Node: fetching images by URL — the full option reference.
- Image Understanding — the integration overview.
- Recipe: Channel Agent with File Intake and a Partner Write-Back — the same marker convention for a document use case.
- Authentication — the three credentials.