Sharing generated files (artifacts)
An artifact is a durable file produced by an agent (or uploaded into the platform) and exposed through a stable reference with a signed and a public URL. Artifacts let generated images, documents, videos and other binary outputs flow from the tool that creates them to the chat surface, to native channels (WhatsApp / Facebook / Instagram / X), to Pipedream components, and to MCP servers — without each tool re-implementing storage or signing.
What is an artifact?
- A durable blob stored by
IArtifactStorage(local disk, Azure Blob, or GCS), in a dedicated container/bucket (kroov-artifacts) for isolation and lifecycle. - Content-agnostic: the store carries an opaque MIME
contentTypeand never inspects bytes. - Scoped to an owner (
OwnerUserId) and optionally to aProjectId,ThreadId, orSessionId. - Reachable through two URLs:
url— a short-lived signed URL (HMAC token for local, SAS for Azure, V4 signed URL for GCS).publicUrl— a stable, headerless endpoint (GET /api/artifacts/{id}?token=<signed>) that renders directly inside<img>/<video>tags and can be fetched by Meta, Slack, or any HTTPS client.
Artifact reference shape
Every tool that produces or consumes an artifact uses the same JSON shape:
{
"id": "art_0123",
"contentType": "image/png",
"url": "https://api/artifacts/art_0123/signed?token=…",
"publicUrl": "https://api/artifacts/art_0123?token=…",
"fileName": "generated-1.png",
"sizeBytes": 84213
}
The model can reference either id (preferred; the platform re-signs on use) or publicUrl directly when calling downstream tools.
Generating an image
The first built-in generation tool is generate_image. It is registered in the agent orchestrator and the in-process Service runtime (no Kubernetes needed) and is gated by Artifacts:Enabled plus an image_generation provider capability.
Schema:
{
"prompt": "A flat vector illustration of a mountain at sunset",
"size": "1024x1024",
"n": 1
}
prompt(required) — text description.size(optional) —1024x1024(default) or1792x1024.n(optional) — number of images to generate (default1).
Result: for each generated image, the tool uploads the bytes to IArtifactStorage and returns an artifact reference. The conventional tool-result shape is:
{
"artifacts": [
{
"id": "art_0123",
"contentType": "image/png",
"url": "…",
"publicUrl": "…",
"fileName": "generated-1.png",
"sizeBytes": 84213
}
]
}
The same artifacts array convention is used by every generation/delivery tool — the model and the chat renderer read it generically.
Generating documents and ZIP archives
generate_document creates a downloadable file without an external model provider. It accepts:
{
"title": "Quarterly report",
"body": "First paragraph.\n\nSecond paragraph.",
"format": "pdf"
}
format is pdf, docx, or txt. PDF and DOCX contain the title followed by paragraphs split on
blank lines; the body is treated as plain text rather than full Markdown. PDFs use a Unicode layout
(word wrap and extra pages) so accented text and long paragraphs stay on the page. TXT is UTF-8
plain text.
generate_zip bundles inline UTF-8 text files, existing accessible artifacts, or both:
{
"fileName": "support-bundle.zip",
"files": [{ "fileName": "notes.txt", "text": "Investigation notes" }],
"artifactIds": ["01234567-89ab-cdef-0123-456789abcdef"]
}
Existing artifactIds are resolved with the current user's artifact access rules before their bytes
are added to the archive. Both tools require Artifacts:Enabled and return the same artifacts
array as generate_image, so assistant chat renders PDF, DOCX, TXT, and ZIP results as download chips.
How artifacts flow to channels
Native channels
The native action executor resolves artifactId → a freshly signed publicUrl before calling the provider client, so each tool only needs to accept an artifactId or a public URL:
- WhatsApp —
whatsapp_business__send_image(to,artifactId|imageUrl, optionalcaption). The client uploads the artifact bytes to Meta's media endpoint and sends animagemessage. Seedocs/WHATSAPP-BUSINESS.mdfor the native WhatsApp connection setup. - Facebook Pages —
facebook__publish_photo(message,artifactId|imageUrl). Posts to/{pageId}/photoswith the artifact'spublicUrl. Seedocs/FACEBOOK.md. - Instagram —
instagram__publish_imagealready accepts a public HTTPSimageUrl; pass the artifact'spublicUrldirectly. Seedocs/INSTAGRAM.md. - X (Twitter) —
x_twitter__post_tweetaccepts an optionalmediaArtifactIds: string[]; the client uploads each artifact throughmedia/upload(chunked) and attaches the returned media ids.
All four go through the existing grant, policy, approval, rate-limit, and action-ledger pipeline. artifactId resolution is content-agnostic — a future video artifact works the same way.
Pipedream
In PipedreamConnectClient.RunActionAsync, props named attachments, file, or attachment that receive { artifactId } (or a bare artifactId string) are resolved to a fetchable shape before the action runs:
- Gmail
attachments: [{ filename, content }]— base64 content is read from the artifact. - Slack
file: { url }— the artifact's signedpublicUrlis injected.
You can also pass publicUrl directly into any prop that expects a public HTTPS URL.
MCP
- Input: when an MCP tool schema accepts a
urlorbase64field, passartifactIdand the executor resolves it to the shape the server expects. A helperIArtifactResolver.ResolveAsync(arg)is available to custom executors. - Output:
McpProtocolClient.NormalizeToolResultnow preserves non-text blocks returned by servers:ImageContentBlock→{ type: "image", mimeType, base64 }, and whenMcp:PersistImageResultsis on, the image is uploaded to the artifact store and the result carries{ artifactId, url }.- Blob resources →
{ type: "resource", uri, mimeType, blob }. Mcp:PersistImageResultsis off by default to avoid unbounded storage growth; enable it when you want server-produced images to render inline and be reusable.
Chat and sessions
Generated images and files render inline in the assistant chat and in agent sessions:
- The assistant message binds an
AssistantAttachmentwithKind = Generated;MessageAttachmentsrenders<img>forimage/*,<video>forvideo/*, otherwise a download chip. - The client fetches the signed URL through a blob URL so auth-bearing endpoints render in
<img>tags. MarkdownRendereraccepts anallowImagesoption so agent-authored markdown can display generated images inline.
Configuration
Artifacts__Enabled=true
Artifacts__StorageProvider=Local # Local | Azure | Gcs
Artifacts__PublicBaseUrl=https://<api-host>
Artifacts__SigningKey=<high-entropy HMAC key>
# Provider-specific (mirror AssistantAttachments defaults):
Artifacts__Container=kroov-artifacts # Azure
Artifacts__Bucket=kroov-artifacts # GCS
# Optional — persist images returned by MCP servers:
Mcp__PersistImageResults=false
Artifacts:SigningKey is a secret — supply it via the environment or a secret manager, never in Git. With Artifacts:Enabled and StorageProvider=Local, the API refuses to start if the key is missing. Azure / GCS use provider-native signed URLs instead.
Security
- Signed URLs — short TTL (15 min) for channel delivery, longer (1 h) for chat rendering. Local storage uses an HMAC token query param; Azure uses SAS; GCS uses V4 signed URLs.
- Headerless render —
GET /api/artifacts/{id}?token=<signed>accepts a token query param so<img>/<video>tags work without a bearer header. The token is bound to the artifact id and the signing key. - Per-owner / project scoping — every artifact carries
OwnerUserIdand optionalProjectId/ThreadId/SessionId. Download and sign endpoints verify access against these scopes. - Access checks on download — both the streaming endpoint and the signed-URL endpoint enforce the same row-level access checks; a leaked signed URL expires and is bound to a single artifact.
- Lifecycle — every capture sets
ExpiresAtfrom the owner's retention preference (User.AssetRetentionDays, default 30 days viaArtifacts:DefaultRetentionDays). Pinning clears expiry (ExpiresAt = null) so the file is kept indefinitely.ArtifactPurgeWorkersweeps unpinned rows whose expiry has passed (intervalArtifacts:PurgeIntervalHours, default 6h). Setting retention to0(Never) means new captures never expire.
Extensibility
The architecture separates the content-agnostic core from type-specific tools that are purely additive:
flowchart TD
GenTools["Generation tools (additive)<br/>generate_image, generate_video,<br/>generate_document, generate_zip, ..."] -->|store| Store
UserUpload["user upload"] --> Store
External["MCP/Pipedream binary outputs"] --> Store
Store["IArtifactStorage (content-agnostic)<br/>local/Azure/GCS<br/>+ signed URL"]
Store -->|artifact ref {id,contentType,url,publicUrl}| ToolResult["Tool result JSON"]
ToolResult --> ChatRender["Chat: render by contentType<br/>image -> img, video -> player,<br/>pdf/zip -> download chip"]
ToolResult --> Native["Delivery tools (additive)<br/>WA send_image/_document/_video,<br/>FB publish_photo/_video, X media, IG url"]
Store -->|publicUrl| Pipedream["Pipedream props<br/>Gmail/Slack files"]
Store -->|url/base64| Mcp["MCP tools + restore image/blob blocks"]
- New generation tools (
generate_video,generate_document,generate_zip,generate_audio) — one new tool per capability, calling the appropriate provider, then reusing the sameIArtifactStorage. No core change. - New delivery tools (
whatsapp_business__send_document/_video/_audio,facebook__publish_video, Instagramvideo_urlfor Reels) — one new tool (sometimes plus a client method) per type. No core refactoring. - Richer chat rendering (inline PDF preview, video player) — add branches to the renderer keyed on
contentType. The data model does not change.
Adding a new file type is one or more new tools that produce or consume artifacts through the same abstraction; the store, table, signed URLs, base rendering, and inter-tool transport stay untouched.