Skip to content

File Messaging

Why a Dedicated File Type?

Users often need to send documents, images, or other files to an agent — an invoice to process, a photo to analyze. Agents also need to send files back — a generated PDF, a processed copy, a receipt. Files don't fit the chat or data message model: they are large binary payloads that would blow past Temporal's ~2 MB signal limit if sent inline. So Xians treats files as a first-class message type (File) with its own handler, storage, and delivery pipeline.

The key idea is the same in both directions: bytes live in MongoDB GridFS; conversation history, SSE, and Temporal carry only tiny fileId references. Agent Studio shows a caption plus a filename and download icon. Clicking download is the first time those bytes reach the browser.

sequenceDiagram
    participant C as Client / Agent Studio
    participant S as Xians Server
    participant G as GridFS storage
    participant A as Agent handler

    Note over C,A: User → agent
    C->>S: POST /send/file (base64 files)
    S->>G: Store file bytes (tenant-scoped)
    S->>A: Signal with fileId references only
    A->>G: SDK downloads bytes transparently
    Note over A: context.Message.Files has decoded bytes

    Note over C,A: Agent → user
    A->>S: POST /api/agent/files (base64 bytes)
    S->>G: Store file bytes, return fileId
    A->>S: POST /outbound/file (caption + fileId refs, no content)
    S-->>C: SSE / history (type File, refs only)
    C->>S: GET /messaging/files/{fileId} on download
    S->>G: Stream bytes

Handling File Uploads in Your Agent

Register OnFileUpload on a built-in workflow. Files arrive as typed UploadedFile objects via context.Message.Files — no JSON parsing or manual downloads needed:

C#
var conversationalWorkflow = xiansAgent.Workflows.DefineSupervisor();

conversationalWorkflow.OnFileUpload(async (context) =>
{
    var files = context.Message.Files;

    if (files.Count == 0)
    {
        await context.ReplyAsync("No file data received.");
        return;
    }

    foreach (var file in files)
    {
        if (!file.TryGetBytes(out var fileBytes))
        {
            await context.ReplyAsync($"Invalid file format for '{file.FileName}'.");
            return;
        }

        // Process fileBytes using file.FileName, file.ContentType...
        await context.ReplyAsync($"Received {file.FileName} ({fileBytes!.Length} bytes)");
    }
});

The handler receives the same UserMessageContext as chat handlers, so ReplyAsync, SendFileAsync, SendDataAsync, and GetChatHistoryAsync all work as usual. An optional caption sent by the client is in context.Message.Text.

The UploadedFile Type

Member Type Description
Content string Base64 content (resolved automatically for GridFS-backed files)
FileName string? File name, if provided
ContentType string? MIME type, e.g. application/pdf
FileSize long? Size in bytes, if provided
FileId string? Server storage ID (GridFS); null until uploaded, or set when forwarding
FromBytes(byte[], fileName, contentType?) UploadedFile Build a file from agent-generated bytes (FileId is null so the SDK uploads); throws on an unsafe file name
GetBytes() byte[] Decode to raw bytes (throws on invalid base64)
TryGetBytes(out byte[]?) bool Decode without throwing

Accepted Payload Formats

context.Message.Files decodes every wire format a File message can arrive in. You normally don't care which one was used — the result is always a list of UploadedFile:

Format data payload Notes
Reference { "files": [{ "fileId", "fileName", ... }] } What the server stores/delivers; bytes fetched automatically
Multi-file inline { "files": [{ "content", "fileName", ... }] } Backward compatibility / direct calls
Single file object { "content", "fileName", ... } Backward compatibility
Raw base64 string "JVBERi0x..." context.Message.Text becomes the file name

Unrecognizable data yields an empty list — it never throws.

Sending Files from the Agent

Call SendFileAsync (or ReplyWithFileAsync) from a message handler or activityOnUserChatMessage, OnFileUpload, and so on. Handlers run inside an activity, so the bytes go straight to the platform over HTTP and the full limits below apply.

Sending from deterministic workflow code works too, through XiansContext.Messaging — see From workflow code. There the send runs as a Temporal activity, so newly generated bytes pass through workflow history and a much tighter limit applies.

The SDK uploads bytes over HTTP (POST /api/agent/files), then posts an outbound File message with references only (POST /api/agent/conversation/outbound/file). History and SSE never include content.

C#
using Xians.Lib.Agents.Messaging;

// Reply + file in one Agent Studio bubble (caption, then filename + download icon)
await context.ReplyWithFileAsync(
    "Here is your report.",
    UploadedFile.FromBytes(pdfBytes, "report.pdf", "application/pdf"));

// Same thing via SendFileAsync
await context.SendFileAsync(
    content: pdfBytes,
    fileName: "report.pdf",
    contentType: "application/pdf",
    text: "Here is your report.");

// Several files (maximum 5)
await context.SendFileAsync(files, text: "Here are the exports.");

// A caption plus several files, in one bubble
await context.ReplyWithFilesAsync("Here are the exports.", files);

// Forward a file the user just uploaded (has FileId after hydrate — not re-uploaded)
await context.SendFileAsync(context.Message.Files, text: "Echoing your upload");
Method Result in Agent Studio
ReplyWithFileAsync(text, file) or SendFileAsync(..., text: "...") One agent bubble: caption + filename + download icon
ReplyAsync(text) then SendFileAsync(...) Two bubbles: Chat, then File
SendFileAsync with empty text Caption falls back to “Your file is ready.”

The file variants are named ReplyWithFileAsync / ReplyWithFilesAsync rather than being overloads of ReplyAsync, which is already overloaded for data replies (ReplyAsync(text, data)).

SkipResponse suppresses the upload and the outbound message, same as ReplyAsync.

Requires a server version that exposes POST /api/agent/files and POST /api/agent/conversation/outbound/file.

From workflow code

Handlers hand you a UserMessageContext, but custom workflow code has none, so use XiansContext.Messaging. SendFileAsSupervisorAsync puts the files in the user's chat conversation; SendFileAsync puts them in the current workflow's own conversation.

Because the send is a Temporal activity, the request is serialized into workflow history. That splits the two cases apart:

  • Files the agent generates here carry their bytes in the activity argument, capped at 1,500,000 base64 characters combined — roughly 1.1 MB of file bytes. Larger files belong in a handler or in your own activity. The cap is measured on the base64 text because that is what Temporal writes to history, and the failure message quotes character counts rather than megabytes.
  • Files the platform already stores carry only a FileId, so they are forwarded by reference: nothing lands in history, no second upload, and the normal limits apply unchanged.
C#
[Workflow("My Agent:Report Workflow")]
public class ReportWorkflow
{
    [WorkflowRun]
    public async Task RunAsync(string participantId, IReadOnlyList<UploadedFile> uploads)
    {
        // Generated bytes pass through workflow history — keep them small.
        var report = Encoding.UTF8.GetBytes("...");
        await XiansContext.Messaging.SendFileAsSupervisorAsync(
            [UploadedFile.FromBytes(report, "report.txt", "text/plain")],
            text: "Here is your report.",
            participantId: participantId);

        // Files with a FileId are forwarded by reference: nothing lands in history.
        await XiansContext.Messaging.SendFileAsSupervisorAsync(
            uploads, text: "Echoing your uploads", participantId: participantId);
    }
}

Pass uploads into a workflow as references only, so the workflow argument itself stays small:

C#
var storedFiles = context.Message.Files
    .Where(file => !string.IsNullOrEmpty(file.FileId))
    .Select(file => new UploadedFile(null, file.FileName, file.ContentType, file.FileSize, file.FileId))
    .ToList();

Bad input — too many files, a missing or unsafe file name, anything over the size limits — fails the workflow immediately with a non-retryable error naming the problem. These are validated in workflow code, before any activity is scheduled, so a send that can never succeed does not sit through the retry backoff first.

Retries do not duplicate stored files

From workflow code the upload and the outbound message are two separate activities, so the upload result is recorded in workflow history. If posting the message fails and Temporal retries it, the already-stored files are reused instead of being uploaded again.

Handlers get no such checkpoint: a handler that fails after sending is retried from the top, so anything it uploaded before the failure stays in storage until it expires. Keep uploads near the end of a handler if you expect it to be retried.

Slack and Teams

Outbound File messages are persisted and shown in Agent Studio. App-channel routers skip them in v1 — Slack/Teams do not receive the attachment.

Sending Files from Client Applications

Use the specialized file endpoint of the Messaging Admin API:

Text Only
POST /api/v1/admin/tenants/{tenantId}/messaging/send/file

Request Fields

Field Required Description
agentName Yes Target agent
activationName Yes Workflow instance name
participantId Yes User sending the file
files Yes Array of { content (base64), fileName, contentType, fileSize? }
text No Caption to accompany the files
workflowType No Defaults to "Supervisor Workflow"
topic No Scope for the message thread
requestId, hint, origin, authorization No Standard messaging options

Limits

Same limits apply to user uploads and agent sends:

Limit Value
Files per message 5
Size per file (decoded) 10 MB
Combined size per message 20 MB
GridFS retention 180 days (metadata.expires_at)

Violations are rejected with 400 Bad Request. After 180 days the GridFS object is deleted; history may still show the filename, but download returns 404.

Two extra limits apply only to the SDK. Newly generated files sent from workflow code are capped at 1,500,000 base64 characters combined (~1.1 MB of file bytes), because the bytes travel through workflow history; files forwarded by reference are exempt. See From workflow code. And every file name must be a plain name of 255 characters or fewer — path separators (/, \) and control characters are rejected, including on files you forward by reference, since the name you pass is the one stored and shown to the user.

Example

Bash
curl -X POST "https://your-server/api/v1/admin/tenants/default/messaging/send/file" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentName": "DocumentAgent",
    "activationName": "DocumentAgent - Default",
    "participantId": "user@example.com",
    "text": "Here are the invoice and the receipt.",
    "files": [
      { "content": "JVBERi0x...", "fileName": "invoice.pdf", "contentType": "application/pdf" },
      { "content": "iVBORw0K...", "fileName": "receipt.png", "contentType": "image/png" }
    ],
    "topic": "document-uploads"
  }'

The tenant can be given in the URL path or via the X-Tenant-Id header.

Legacy generic endpoint

The generic POST .../messaging/send endpoint still accepts type: "File" with files nested under data (including the raw-base64-string form). New integrations should use /send/file — it has a validated, self-documenting schema.

Downloading Stored Files

Stored messages carry references, not bytes, so message history and SSE streams stay lightweight. Fetch content on demand — all of these enforce tenant isolation:

Consumer Endpoint Auth
Agent Studio (browser) GET /api/messaging/files/{fileId} Session cookie (BFF → Admin GET)
Client / Admin API GET /api/v1/admin/tenants/{tenantId}/messaging/files/{fileId} Admin API key (Bearer)
Agent SDK GET /api/agent/files/{fileId} Client certificate

Agents rarely call the download endpoint directly — context.Message.Files already resolves inbound bytes for you. Agent Studio renders outbound and inbound file attachments automatically: caption text, then a compact filename row whose download icon (not the filename) starts the download. No extra Studio setup is required.

Summary

Aspect Detail
Receive (user → agent) workflow.OnFileUpload(async context => { ... })
Send (agent → user) context.SendFileAsync(...) or context.ReplyWithFileAsync(text, file) from a handler/activity
Send from workflow code XiansContext.Messaging.SendFileAsSupervisorAsync(files, text, participantId)
Typed access UploadedFile / UploadedFile.FromBytes / context.Message.Files
Storage Bytes in MongoDB GridFS; messages carry { fileId, fileName, contentType, fileSize }
User send endpoint POST .../messaging/send/file
Agent upload then message POST /api/agent/files then POST /api/agent/conversation/outbound/file
Studio download GET /api/messaging/files/{fileId}
Limits 5 files, 10 MB each, 20 MB combined, 255-char file names, 180-day retention
Limit from workflow code New files capped at 1,500,000 base64 chars combined (~1.1 MB of bytes); references exempt