NOUKAI

Using Attachments

Pass images into your flow alongside the message — vision input for multimodal LLM blocks.

Attachments let you feed images into your flow alongside message. Each LLM block that uses a vision-capable model can read both the text and the image when generating its output.

This guide covers what's supported, how to host the file, how to send the request, and how to handle errors.

How It Works

Noukai is stateless w.r.t. attachment bytes. You host the file on any HTTPS-reachable URL — your CDN, S3 bucket, Supabase storage, a public asset host, etc. — and pass the URL through. Noukai validates the URL syntax, checks the MIME type against an allowlist, verifies the chosen model supports it, then forwards the URL straight to the LLM provider (OpenRouter).

your client ──► your CDN ──► (URL) ──► Noukai API ──► OpenRouter ──► model
                  ▲                                      │
                  └──────── model fetches the URL ◄──────┘

What this means:

  • You own hosting. Noukai never receives, stores, or proxies the bytes.
  • The URL must be publicly reachable by OpenRouter's fetcher. A signed URL works as long as it's still valid when the request runs.
  • No upload step. There's no Noukai endpoint to POST a file to — you point at a file you've already hosted.

What's Supported

v1 supports images only via URL passthrough.

MIME typeNotes
image/jpeg
image/png
image/webp
image/gifMost models read the first frame only
LimitValue
Max attachments per request10
URL schemehttps:// only
Max URL length2048 chars

The Attachment Object

Every attachment is a JSON object with this shape:

{
  "kind": "url",
  "url": "https://customer-cdn.example.com/uploads/photo.png",
  "mime_type": "image/png",
  "filename": "photo.png"
}
FieldRequiredDescription
kindYesDiscriminator. Must be "url".
urlYesHTTPS URL to the image.
mime_typeYesMIME type from the supported list.
filenameNoDisplay name for run history (≤ 255 chars, no /, \, or ..).

Walkthrough

Pick a vision-capable model on the receiving block

Open your flow in app.noukai.xyz and set the LLM block's model to one that accepts images:

  • google/gemini-2.0-flash-001
  • anthropic/claude-sonnet-4-6
  • openai/gpt-4o
  • openai/gpt-4o-mini

If a block's model doesn't support images and you send an image attachment, the request is rejected with 400 MODEL_MIME_INCOMPATIBLE before execution starts.

Reference the attachment in your prompt

Inside the block's prompt, write naturally — the model receives the image alongside the prompt text automatically. You don't need a placeholder for the image itself.

Look at the attached image. Describe the layout in two sentences,
then list any visible text exactly as it appears.

User's question: {message}

Host the image somewhere HTTPS-reachable

Examples:

  • Public S3 bucket / CloudFront distribution
  • Supabase storage with a public bucket or signed URL
  • Cloudflare R2 / Backblaze B2 with public access
  • Your existing app's CDN

The URL must work when fetched by OpenRouter's outbound network — private IPs, localhost, and intranet hosts are blocked at validation time.

Send the request

Top-level attachments array, alongside message:

{
  "message": "What's in this image?",
  "attachments": [
    {
      "kind": "url",
      "url": "https://your-cdn.example.com/img/photo.png",
      "mime_type": "image/png"
    }
  ]
}

Code Examples

async function describeImage(question: string, imageUrl: string) {
  const res = await fetch(
    `${process.env.NOUKAI_API_URL}/api/v1/seq/acme-corp/support-bot/describe-image/execute`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NOUKAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message: question,
        attachments: [
          {
            kind: "url",
            url: imageUrl,
            mime_type: "image/png",
          },
        ],
      }),
    },
  );
 
  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new Error(
      `Noukai error ${res.status}: ${body?.detail?.code ?? "unknown"} — ${body?.detail?.message ?? res.statusText}`,
    );
  }
 
  const { result } = await res.json();
  return result;
}

Multiple Attachments

You can attach up to 10 files in one request. All blocks in the flow see the same attachments — there is no per-block routing in v1.

{
  "message": "Compare these two screenshots and list every visual difference.",
  "attachments": [
    {
      "kind": "url",
      "url": "https://your-cdn.example.com/before.png",
      "mime_type": "image/png",
      "filename": "before.png"
    },
    {
      "kind": "url",
      "url": "https://your-cdn.example.com/after.png",
      "mime_type": "image/png",
      "filename": "after.png"
    }
  ]
}

Non-LLM blocks (code, passthrough) receive the attachments array on BlockContext.initial.attachments but ignore it by default — only LLM blocks send the images to the model.

Async Execution

Attachments work identically with the async /jobs endpoint. The request body is the same; the response is 202 with an executionId, and the attachments are persisted on the run record so polling and run-history rendering see them.

POST /api/v1/seq/{org}/{project}/{flow}/jobs

Error Handling

Validation runs before any execution work, so attachment problems fail fast with a structured 400:

{
  "detail": {
    "code": "ATTACHMENT_UNSUPPORTED_MIME",
    "message": "attachments[0].mime_type 'image/heic' is not supported.",
    "unsupported_mime": "image/heic"
  }
}
CodeFix
PARAMETER_NAME_RESERVEDYou put attachments inside parameters. Move it to the top level.
ATTACHMENT_LIMIT_EXCEEDEDSend ≤ 10 attachments per request.
ATTACHMENT_INVALID_SCHEMEUse https://. http://, data:, and blob: are rejected.
ATTACHMENT_BLOCKED_HOSTHostname resolved to a private/loopback IP. Use a public CDN.
ATTACHMENT_URL_TOO_LONGTrim the URL or use a shorter signed-URL strategy.
ATTACHMENT_UNSUPPORTED_MIMECheck the supported MIME table above. Convert HEIC/AVIF/etc. before uploading.
ATTACHMENT_INVALID_FILENAMEStrip path separators (/, \, ..) and keep filename ≤ 255 chars.
MODEL_MIME_INCOMPATIBLESwitch the named block's model to a vision-capable one. The error includes step_id, model, and unsupported_mime.

A complete reference (including CAPABILITY_REGISTRY_UNAVAILABLE) lives in the Slug Execution API Reference.

The image URL must still be live when the model fetches it. Signed URLs that have expired between when you sent the Noukai request and when the model dispatches will surface as a runtime error from OpenRouter. Use long-enough TTLs or public URLs for production traffic.

Privacy and Security

  • Bytes are never stored by Noukai. Only the URL string and metadata are persisted on the run record.
  • SSRF is blocked. Private/loopback/link-local hostnames are rejected at the API boundary; the validator re-resolves the hostname and checks against blocked CIDR ranges.
  • No HEAD pre-check. Noukai does not pre-fetch your URL to verify reachability — that would add latency and create a denial-of-service vector for the customer's CDN. Liveness is verified at model-dispatch time.

Common Patterns

Vision-driven classification

{
  "message": "Categorize this product photo.",
  "attachments": [{
    "kind": "url",
    "url": "https://shop.example.com/products/sku-1234.jpg",
    "mime_type": "image/jpeg"
  }]
}

Pair with an output schema like {"category": "string", "confidence": "number"} on the LLM block to get structured output.

Multi-image diff/comparison

Attach two images and ask the model to compare them. Works well for QA screenshots, before/after photos, design review.

User-uploaded media in your app

Workflow:

  1. User uploads an image to your storage (S3 / Supabase / R2).
  2. Your backend gets back the public URL (or signs one with a 30-minute TTL).
  3. Your backend calls Noukai with the URL in attachments.
  4. Noukai returns the model's structured output.

No additional Noukai endpoint, no separate "upload" step.

Next Steps